Skip to main content

omni_dev/claude/
model_config.rs

1//! AI model configuration and specifications.
2//!
3//! # Data model
4//!
5//! [`ModelConfiguration`] is the top-level container. It owns a
6//! <code>Vec<[ModelSpec]></code> of every known model and a
7//! <code>HashMap<String, [ProviderConfig]></code> keyed by provider name.
8//! [`ModelSpec`] records the per-model limits, generation, tier name, and
9//! any [`BetaHeader`]s that unlock enhanced limits. [`ProviderConfig`]
10//! records provider-wide settings β€” including a [`TierInfo`] map describing
11//! each named tier and a [`DefaultConfig`] block used as the fallback for
12//! unknown identifiers from that provider. Every entry carries a
13//! [`ModelSource`] tag identifying which layer contributed it.
14//!
15//! [`ModelRegistry`] wraps a fully merged [`ModelConfiguration`] and adds
16//! identifier-normalised lookup (so a Bedrock or AWS-direct identifier
17//! resolves to the same [`ModelSpec`] as the canonical Anthropic form).
18//!
19//! # Loader
20//!
21//! [`ModelRegistry::load`] builds the registry from a layered set of YAML
22//! sources: an embedded catalog (compile-time `include_str!`), an optional
23//! user-level file at `~/.omni-dev/models.yaml`, and an optional
24//! project-local file at `./.omni-dev/models.yaml`. Layers are deep-merged
25//! with project > user > embedded precedence; an explicit override path
26//! provided via `OMNI_DEV_MODELS_YAML` short-circuits the user/project
27//! lookup. See [ADR-0022](../../docs/adrs/adr-0022.md) for the layered
28//! loader rationale and [ADR-0011](../../docs/adrs/adr-0011.md) for the
29//! original compile-time design.
30
31use std::collections::HashMap;
32use std::path::{Path, PathBuf};
33use std::sync::OnceLock;
34
35use anyhow::{anyhow, Result};
36use serde::{Deserialize, Serialize};
37
38/// Embedded models YAML configuration, loaded at compile time.
39pub(crate) const MODELS_YAML: &str = include_str!("../templates/models.yaml");
40
41/// Schema version that this build of omni-dev understands.
42///
43/// User/project files declaring a different version receive a warning at
44/// load time. Files without a `version:` field are accepted with a warning
45/// for backwards compatibility.
46pub const MODELS_SCHEMA_VERSION: &str = "1";
47
48/// Environment variable that, when set, points at a single user-side YAML
49/// file and short-circuits the standard user/project lookup.
50pub const OMNI_DEV_MODELS_YAML_ENV: &str = "OMNI_DEV_MODELS_YAML";
51
52/// Ultimate fallback max output tokens when no model or provider config matches.
53const FALLBACK_MAX_OUTPUT_TOKENS: usize = 4096;
54
55/// Ultimate fallback input context when no model or provider config matches.
56const FALLBACK_INPUT_CONTEXT: usize = 100_000;
57
58/// Layer that contributed a model or provider entry.
59#[derive(
60    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default,
61)]
62#[serde(rename_all = "lowercase")]
63pub enum ModelSource {
64    /// Compile-time embedded catalog (`src/templates/models.yaml`).
65    #[default]
66    Embedded,
67    /// User-level catalog at `~/.omni-dev/models.yaml`.
68    User,
69    /// Project-local catalog at `./.omni-dev/models.yaml`.
70    Project,
71    /// File explicitly pointed to by `OMNI_DEV_MODELS_YAML`/`--models-yaml`.
72    Override,
73}
74
75impl std::fmt::Display for ModelSource {
76    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77        f.write_str(match self {
78            Self::Embedded => "embedded",
79            Self::User => "user",
80            Self::Project => "project",
81            Self::Override => "override",
82        })
83    }
84}
85
86/// HTTP header that, when sent on a request, unlocks enhanced limits for a
87/// model.
88///
89/// A [`BetaHeader`] is a leaf of a [`ModelSpec`]: it names the header to
90/// send (`key`/`value`) and records the new ceiling for [`max_output_tokens`]
91/// and/or [`input_context`] that the header makes available. An absent
92/// override field means that header does not move that limit; the model's
93/// base value still applies. Callers consult these via
94/// [`ModelRegistry::get_max_output_tokens_with_beta`] and
95/// [`ModelRegistry::get_input_context_with_beta`].
96///
97/// [`max_output_tokens`]: ModelSpec::max_output_tokens
98/// [`input_context`]: ModelSpec::input_context
99#[derive(Debug, Deserialize, Serialize, Clone)]
100pub struct BetaHeader {
101    /// HTTP header name (e.g., "anthropic-beta").
102    pub key: String,
103    /// Header value (e.g., "context-1m-2025-08-07").
104    pub value: String,
105    /// Overridden max output tokens when this header is active.
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub max_output_tokens: Option<usize>,
108    /// Overridden input context when this header is active.
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub input_context: Option<usize>,
111}
112
113/// The four current Claude model families.
114///
115/// Classified from an API identifier or a [`ModelSpec`]'s human-readable
116/// name β€” distinct from [`ModelSpec::tier`], which groups by
117/// performance/capability rather than the model's name.
118///
119/// Used to render a colour-coded glyph identifying which model a session is
120/// running (`omni-dev claude-wrap`'s terminal-title rewrite, issue #1445): a
121/// terminal tab title is plain text with no ANSI/theme-colour channel, so a
122/// coloured circle emoji stands in for real colour. See
123/// [`ModelRegistry::get_model_family`].
124#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125pub enum ModelFamily {
126    /// The Fable family (e.g. `claude-fable-5`).
127    Fable,
128    /// The Opus family (e.g. `claude-opus-4-8`).
129    Opus,
130    /// The Sonnet family (e.g. `claude-sonnet-5`).
131    Sonnet,
132    /// The Haiku family (e.g. `claude-haiku-4-5`).
133    Haiku,
134    /// A known-but-unclassified, or entirely unrecognized, model id.
135    Unknown,
136}
137
138impl ModelFamily {
139    /// The colour-circle emoji standing in for this family, since a
140    /// terminal tab title is plain text with no colour channel.
141    #[must_use]
142    pub fn glyph(self) -> &'static str {
143        match self {
144            Self::Fable => "🟠",
145            Self::Opus => "🟑",
146            Self::Sonnet => "🟒",
147            Self::Haiku => "πŸ”΅",
148            Self::Unknown => "βšͺ",
149        }
150    }
151
152    /// Short human-readable label for this family.
153    #[must_use]
154    pub fn label(self) -> &'static str {
155        match self {
156            Self::Fable => "Fable",
157            Self::Opus => "Opus",
158            Self::Sonnet => "Sonnet",
159            Self::Haiku => "Haiku",
160            Self::Unknown => "Claude",
161        }
162    }
163}
164
165/// Specification for a single model: its identity, limits, tier, and any
166/// beta-header unlocks.
167///
168/// A [`ModelSpec`] is the central row of the registry. `provider` and
169/// `tier` cross-reference into a [`ProviderConfig`] (via
170/// [`ModelConfiguration::providers`] and [`ProviderConfig::tiers`]).
171/// `max_output_tokens` and `input_context` are the *base* limits; entries
172/// in `beta_headers` raise them when the corresponding HTTP header is sent.
173/// `source` is loader-populated and records which layer contributed the
174/// entry β€” never read from YAML.
175///
176/// # Identifier normalization
177///
178/// The same underlying model is addressable through several identifier
179/// formats depending on how the API is reached:
180///
181/// - Canonical (Anthropic direct): `claude-3-7-sonnet-20250219`
182/// - Bedrock with region prefix: `us.anthropic.claude-3-7-sonnet-20250219-v1:0`
183/// - AWS-direct without region: `anthropic.claude-3-haiku-20240307-v1:0`
184/// - Regional gateways: `eu.anthropic.claude-3-opus-20240229-v2:1`
185///
186/// All four resolve to the same [`ModelSpec`]:
187/// [`ModelRegistry::get_model_spec`] tries an exact match first, and on
188/// miss strips region/provider prefixes and version suffixes before
189/// retrying. See [ADR-0011](../../docs/adrs/adr-0011.md) for the design
190/// rationale.
191#[derive(Debug, Deserialize, Serialize, Clone)]
192pub struct ModelSpec {
193    /// AI provider name (e.g., "claude").
194    pub provider: String,
195    /// Human-readable model name (e.g., "Claude Opus 4").
196    pub model: String,
197    /// API identifier used for requests (e.g., "claude-3-opus-20240229").
198    pub api_identifier: String,
199    /// Maximum number of tokens that can be generated in a single response.
200    pub max_output_tokens: usize,
201    /// Maximum number of tokens that can be included in the input context.
202    pub input_context: usize,
203    /// Model generation number (e.g., 3.0, 3.5, 4.0).
204    pub generation: f32,
205    /// Performance tier (e.g., "fast", "balanced", "flagship").
206    pub tier: String,
207    /// Whether this is a legacy model that may be deprecated.
208    #[serde(default)]
209    pub legacy: bool,
210    /// Announced retirement date (`YYYY-MM-DD`), if the provider has published
211    /// one.
212    ///
213    /// Complements [`Self::legacy`], which cannot distinguish "older but fine"
214    /// from "stops working on a known date" (#1334). `None` covers both models
215    /// with no announced retirement and deprecated models whose date is still
216    /// TBD β€” absence means "no published date", never "not retiring".
217    ///
218    /// A past date means the model is already retired and its identifier now
219    /// `404`s; such entries are retained only so Bedrock/AWS identifier
220    /// normalization keeps resolving them (see ADR-0011).
221    ///
222    /// Held as a plain `String`: nothing parses or compares it today, it is
223    /// carried verbatim into `omni-dev config models show`.
224    #[serde(default, skip_serializing_if = "Option::is_none")]
225    pub retires: Option<String>,
226    /// Whether this model supports structured JSON-schema output via the
227    /// Anthropic Messages API `output_config.format` (and the equivalent on
228    /// Bedrock).
229    ///
230    /// Gates the direct-API / Bedrock schema path (#1119): only models
231    /// flagged here advertise
232    /// [`AiClientCapabilities::supports_response_schema`](crate::claude::ai::AiClientCapabilities::supports_response_schema),
233    /// so a caller on an older model β€” which would `400` on
234    /// `output_config` β€” transparently keeps the YAML fallback. Defaults to
235    /// `false`, so unmarked and unknown models are never sent the field.
236    #[serde(default)]
237    pub supports_structured_output: bool,
238    /// Price per million *input* tokens in USD, if known.
239    ///
240    /// Used to compute per-invocation cost for backends that report token
241    /// usage (currently the direct Anthropic API β€” see
242    /// [`crate::claude::ai::compute_cost_usd`]). `None` for unpriced models
243    /// (e.g. the OpenAI/Gemini entries), which surface cost as unknown.
244    #[serde(default, skip_serializing_if = "Option::is_none")]
245    pub input_token_price: Option<f64>,
246    /// Price per million *output* tokens in USD, if known. See
247    /// [`Self::input_token_price`].
248    #[serde(default, skip_serializing_if = "Option::is_none")]
249    pub output_token_price: Option<f64>,
250    /// Beta headers that unlock enhanced limits for this model.
251    #[serde(default, skip_serializing_if = "Vec::is_empty")]
252    pub beta_headers: Vec<BetaHeader>,
253    /// Layer that contributed this entry. Populated by the loader; never
254    /// read from YAML.
255    #[serde(default, skip_deserializing)]
256    pub source: ModelSource,
257}
258
259/// Human-readable metadata for a named performance tier.
260///
261/// A tier groups models with comparable speed/capability trade-offs
262/// (e.g. `fast`, `balanced`, `flagship`). [`TierInfo`] holds only the
263/// *description* and recommended use cases β€” the *limits* (output tokens,
264/// input context, beta-header unlocks) live on each [`ModelSpec`], not
265/// here. [`TierInfo`] is stored in [`ProviderConfig::tiers`] keyed by tier
266/// name, and the same tier name appears on [`ModelSpec::tier`] to link a
267/// model into its tier.
268#[derive(Debug, Deserialize, Serialize, Clone)]
269pub struct TierInfo {
270    /// Human-readable description of the tier.
271    pub description: String,
272    /// List of recommended use cases for this tier.
273    pub use_cases: Vec<String>,
274}
275
276/// Provider-wide fallback limits used when a requested identifier does not
277/// match any [`ModelSpec`].
278///
279/// [`ModelRegistry::get_max_output_tokens`] and
280/// [`ModelRegistry::get_input_context`] consult these values whenever the
281/// caller passes an identifier the registry has not seen β€” typically a
282/// brand-new model the embedded catalog has not yet been updated for, but
283/// whose provider can still be inferred from the identifier shape. If the
284/// provider itself cannot be inferred, an ultimate hard-coded fallback in
285/// this module applies instead.
286#[derive(Debug, Deserialize, Serialize, Clone)]
287pub struct DefaultConfig {
288    /// Default maximum output tokens for unknown models from this provider.
289    pub max_output_tokens: usize,
290    /// Default input context limit for unknown models from this provider.
291    pub input_context: usize,
292}
293
294/// Per-provider settings: endpoint, default model, named tiers, and the
295/// fallback limits for unknown identifiers.
296///
297/// One [`ProviderConfig`] exists per AI vendor (Anthropic Claude, OpenAI,
298/// Bedrock, Ollama, …) and is stored in [`ModelConfiguration::providers`]
299/// keyed by provider name. `tiers` maps tier names to [`TierInfo`]
300/// descriptions; the same names appear on [`ModelSpec::tier`]. `defaults`
301/// is the per-provider [`DefaultConfig`] used as a fallback when a model
302/// identifier does not match any [`ModelSpec`]. `source` is
303/// loader-populated and records the highest-precedence layer that
304/// contributed any field to this provider block.
305#[derive(Debug, Deserialize, Serialize, Clone)]
306pub struct ProviderConfig {
307    /// Human-readable provider name.
308    pub name: String,
309    /// Base URL for API requests.
310    pub api_base: String,
311    /// Default model identifier to use if none specified.
312    pub default_model: String,
313    /// Available performance tiers and their descriptions.
314    pub tiers: HashMap<String, TierInfo>,
315    /// Default configuration for unknown models.
316    pub defaults: DefaultConfig,
317    /// Layer that contributed this provider block. Populated by the loader.
318    #[serde(default, skip_deserializing)]
319    pub source: ModelSource,
320}
321
322/// Top-level deserialised model catalog: every known model plus every
323/// provider's settings.
324///
325/// [`ModelConfiguration`] is the result of merging the embedded
326/// `src/templates/models.yaml` with any optional user
327/// (`~/.omni-dev/models.yaml`) and project (`./.omni-dev/models.yaml`)
328/// overrides, in that precedence order. See
329/// [ADR-0022](../../docs/adrs/adr-0022.md) for the layered loader and
330/// merge semantics. The canonical entry point that produces a fully merged
331/// instance β€” and wraps it in lookup indices β€” is [`ModelRegistry::load`];
332/// the raw configuration is reachable from there via
333/// [`ModelRegistry::config`].
334#[derive(Debug, Deserialize, Serialize, Clone)]
335pub struct ModelConfiguration {
336    /// Schema version declared by the source YAML, if any.
337    #[serde(default, skip_serializing_if = "Option::is_none")]
338    pub version: Option<String>,
339    /// List of all available models.
340    pub models: Vec<ModelSpec>,
341    /// Provider-specific configurations.
342    pub providers: HashMap<String, ProviderConfig>,
343}
344
345/// Indexed view over a [`ModelConfiguration`] with identifier-normalised
346/// lookup.
347///
348/// [`ModelRegistry`] owns the merged catalog and two auxiliary indices β€”
349/// by API identifier and by provider β€” populated at construction time.
350/// Construct one with [`ModelRegistry::load`], which performs the layered
351/// YAML load described on [`ModelConfiguration`]. Most callers use the
352/// process-wide singleton returned by [`get_model_registry`] rather than
353/// loading their own instance.
354pub struct ModelRegistry {
355    config: ModelConfiguration,
356    by_identifier: HashMap<String, ModelSpec>,
357    by_provider: HashMap<String, Vec<ModelSpec>>,
358}
359
360impl ModelRegistry {
361    /// Loads the model registry, layering an optional user-side catalog
362    /// over the embedded one.
363    ///
364    /// Lookup order (highest precedence wins):
365    /// 1. `OMNI_DEV_MODELS_YAML` β€” explicit override path; short-circuits 2 & 3.
366    /// 2. `./.omni-dev/models.yaml` β€” project-local catalog (if present).
367    /// 3. `~/.omni-dev/models.yaml` β€” user-level catalog (if present).
368    /// 4. Embedded `src/templates/models.yaml` β€” always present, lowest layer.
369    ///
370    /// Missing user-side files fall through silently. Malformed user-side
371    /// files log an error and are skipped. A malformed embedded catalog is
372    /// a hard failure (compile-time invariant).
373    pub fn load() -> Result<Self> {
374        let override_path = std::env::var(OMNI_DEV_MODELS_YAML_ENV)
375            .ok()
376            .filter(|s| !s.is_empty())
377            .map(PathBuf::from);
378        let project_path = default_project_path();
379        let user_path = default_user_path();
380        Self::load_layered_from_paths(
381            project_path.as_deref(),
382            user_path.as_deref(),
383            override_path.as_deref(),
384        )
385    }
386
387    /// Loads the registry with explicit paths for the user-side layers.
388    ///
389    /// Exposed primarily for testing β€” the public entry point is `load()`.
390    pub fn load_layered_from_paths(
391        project_path: Option<&Path>,
392        user_path: Option<&Path>,
393        override_path: Option<&Path>,
394    ) -> Result<Self> {
395        let mut layers: Vec<(ModelSource, String)> = Vec::new();
396        layers.push((ModelSource::Embedded, MODELS_YAML.to_string()));
397
398        if let Some(path) = override_path {
399            match read_optional_yaml(path) {
400                Some(yaml) => layers.push((ModelSource::Override, yaml)),
401                None => {
402                    tracing::warn!(
403                        "{OMNI_DEV_MODELS_YAML_ENV} points at {} but the file is missing or unreadable; falling back to embedded catalog",
404                        path.display()
405                    );
406                }
407            }
408        } else {
409            if let Some(path) = user_path {
410                if let Some(yaml) = read_optional_yaml(path) {
411                    layers.push((ModelSource::User, yaml));
412                }
413            }
414            if let Some(path) = project_path {
415                if let Some(yaml) = read_optional_yaml(path) {
416                    layers.push((ModelSource::Project, yaml));
417                }
418            }
419        }
420
421        Self::from_layers(&layers)
422    }
423
424    /// Builds the registry from already-loaded YAML sources.
425    ///
426    /// `layers` must be ordered from lowest to highest precedence; the
427    /// first entry is treated as the embedded catalog and a parse failure
428    /// there is a hard error.
429    pub(crate) fn from_layers(layers: &[(ModelSource, String)]) -> Result<Self> {
430        let mut merged: serde_yaml::Value =
431            serde_yaml::Value::Mapping(serde_yaml::Mapping::default());
432        let mut model_sources: HashMap<String, ModelSource> = HashMap::new();
433        let mut provider_sources: HashMap<String, ModelSource> = HashMap::new();
434        let mut declared_versions: Vec<(ModelSource, Option<String>)> = Vec::new();
435
436        for (source, yaml) in layers {
437            let value: serde_yaml::Value = match serde_yaml::from_str(yaml) {
438                Ok(v) => v,
439                Err(e) => {
440                    if matches!(source, ModelSource::Embedded) {
441                        return Err(anyhow!(
442                            "Embedded models.yaml is malformed at compile time: {e}"
443                        ));
444                    }
445                    tracing::error!(
446                        "Malformed {source} models.yaml: {e}. Falling through to lower-precedence layers."
447                    );
448                    continue;
449                }
450            };
451
452            // Track version declared by this layer.
453            let version = value
454                .get("version")
455                .and_then(|v| v.as_str())
456                .map(String::from);
457            declared_versions.push((*source, version));
458
459            merge_layer_into(
460                &mut merged,
461                value,
462                *source,
463                &mut model_sources,
464                &mut provider_sources,
465            );
466        }
467
468        warn_on_version_mismatch(&declared_versions);
469
470        let mut config: ModelConfiguration = serde_yaml::from_value(merged)
471            .map_err(|e| anyhow!("Failed to deserialize merged model configuration: {e}"))?;
472
473        for spec in &mut config.models {
474            spec.source = model_sources
475                .get(&spec.api_identifier)
476                .copied()
477                .unwrap_or_default();
478        }
479        for (name, prov) in &mut config.providers {
480            prov.source = provider_sources.get(name).copied().unwrap_or_default();
481        }
482
483        let mut by_identifier = HashMap::new();
484        let mut by_provider: HashMap<String, Vec<ModelSpec>> = HashMap::new();
485        for model in &config.models {
486            by_identifier.insert(model.api_identifier.clone(), model.clone());
487            by_provider
488                .entry(model.provider.clone())
489                .or_default()
490                .push(model.clone());
491        }
492
493        Ok(Self {
494            config,
495            by_identifier,
496            by_provider,
497        })
498    }
499
500    /// Returns the merged model configuration.
501    #[must_use]
502    pub fn config(&self) -> &ModelConfiguration {
503        &self.config
504    }
505
506    /// Returns the model specification for the given API identifier.
507    #[must_use]
508    pub fn get_model_spec(&self, api_identifier: &str) -> Option<&ModelSpec> {
509        // Try exact match first
510        if let Some(spec) = self.by_identifier.get(api_identifier) {
511            return Some(spec);
512        }
513
514        // Try normalizing the identifier and looking up again
515        self.find_model_by_normalized_id(api_identifier)
516    }
517
518    /// Returns the max output tokens for a model, with fallback to provider defaults.
519    #[must_use]
520    pub fn get_max_output_tokens(&self, api_identifier: &str) -> usize {
521        if let Some(spec) = self.get_model_spec(api_identifier) {
522            return spec.max_output_tokens;
523        }
524
525        // Try to infer provider from model identifier and use defaults
526        if let Some(provider) = self.infer_provider(api_identifier) {
527            if let Some(provider_config) = self.config.providers.get(&provider) {
528                return provider_config.defaults.max_output_tokens;
529            }
530        }
531
532        // Ultimate fallback
533        FALLBACK_MAX_OUTPUT_TOKENS
534    }
535
536    /// Returns the input context limit for a model, with fallback to provider defaults.
537    #[must_use]
538    pub fn get_input_context(&self, api_identifier: &str) -> usize {
539        if let Some(spec) = self.get_model_spec(api_identifier) {
540            return spec.input_context;
541        }
542
543        // Try to infer provider from model identifier and use defaults
544        if let Some(provider) = self.infer_provider(api_identifier) {
545            if let Some(provider_config) = self.config.providers.get(&provider) {
546                return provider_config.defaults.input_context;
547            }
548        }
549
550        // Ultimate fallback
551        FALLBACK_INPUT_CONTEXT
552    }
553
554    /// Returns whether the model supports structured JSON-schema output
555    /// (`output_config.format`).
556    ///
557    /// Backs the schema-capability gate for the direct Anthropic and Bedrock
558    /// backends (#1119). Resolves through the same identifier normalization as
559    /// the other lookups, so Bedrock/region-prefixed forms map to the same
560    /// [`ModelSpec`]. Any model not present in the catalog β€” or present but not
561    /// flagged β€” returns `false`, keeping it on the YAML path rather than
562    /// risking a `400` from an unsupported `output_config`.
563    #[must_use]
564    pub fn supports_structured_output(&self, api_identifier: &str) -> bool {
565        self.get_model_spec(api_identifier)
566            .is_some_and(|spec| spec.supports_structured_output)
567    }
568
569    /// Infers the provider from a model identifier.
570    ///
571    /// Consulted only as a fallback when an identifier does not match any
572    /// known [`ModelSpec`]: the inferred provider selects which provider's
573    /// [`DefaultConfig`] (from [`ProviderConfig::defaults`]) supplies the
574    /// fallback limits. Recognised identifier shapes:
575    ///
576    /// - `claude`: starts with `claude`, or contains `anthropic` (covers the
577    ///   Bedrock/AWS region-prefixed forms).
578    /// - `openai`: starts with `gpt` or `chatgpt`, or is an `o<N>` reasoning
579    ///   identifier (`o1-mini`, `o3`, `o4-mini`, …).
580    /// - `gemini`: starts with `gemini`.
581    ///
582    /// Returns `None` when the provider cannot be inferred, in which case the
583    /// caller applies the ultimate hard-coded fallback.
584    fn infer_provider(&self, api_identifier: &str) -> Option<String> {
585        let id = api_identifier;
586        if id.starts_with("claude") || id.contains("anthropic") {
587            Some("claude".to_string())
588        } else if id.starts_with("gpt") || id.starts_with("chatgpt") || is_openai_reasoning_id(id) {
589            Some("openai".to_string())
590        } else if id.starts_with("gemini") {
591            Some("gemini".to_string())
592        } else {
593            None
594        }
595    }
596
597    /// Finds a model by normalizing the identifier and performing an exact lookup.
598    ///
599    /// Handles Bedrock-style (`us.anthropic.claude-3-7-sonnet-20250219-v1:0`),
600    /// AWS-style (`anthropic.claude-3-haiku-20240307-v1:0`), and standard identifiers.
601    fn find_model_by_normalized_id(&self, api_identifier: &str) -> Option<&ModelSpec> {
602        let core_identifier = self.extract_core_model_identifier(api_identifier);
603        self.by_identifier.get(&core_identifier)
604    }
605
606    /// Extracts the core model identifier from various formats.
607    fn extract_core_model_identifier(&self, api_identifier: &str) -> String {
608        let mut identifier = api_identifier.to_string();
609
610        // Remove region prefixes (us., eu., etc.)
611        if let Some(dot_pos) = identifier.find('.') {
612            if identifier[..dot_pos].len() <= 3 {
613                // likely a region code
614                identifier = identifier[dot_pos + 1..].to_string();
615            }
616        }
617
618        // Remove provider prefixes (anthropic.)
619        if identifier.starts_with("anthropic.") {
620            identifier = identifier["anthropic.".len()..].to_string();
621        }
622
623        // Remove version suffixes (-v1:0, -v2:1, etc.)
624        if let Some(version_pos) = identifier.rfind("-v") {
625            if identifier[version_pos..].contains(':') {
626                identifier = identifier[..version_pos].to_string();
627            }
628        }
629
630        identifier
631    }
632
633    /// Checks if a model is legacy.
634    #[must_use]
635    pub fn is_legacy_model(&self, api_identifier: &str) -> bool {
636        self.get_model_spec(api_identifier)
637            .is_some_and(|spec| spec.legacy)
638    }
639
640    /// Returns all available models.
641    #[must_use]
642    pub fn get_all_models(&self) -> &[ModelSpec] {
643        &self.config.models
644    }
645
646    /// Returns models filtered by provider.
647    #[must_use]
648    pub fn get_models_by_provider(&self, provider: &str) -> Vec<&ModelSpec> {
649        self.by_provider
650            .get(provider)
651            .map(|models| models.iter().collect())
652            .unwrap_or_default()
653    }
654
655    /// Reports whether `identifier` names a model this registry knows.
656    ///
657    /// Goes through [`ModelRegistry::get_model_spec`], so Bedrock-style
658    /// identifiers (`us.anthropic.…-v1:0`) are normalised before lookup and
659    /// user-supplied `models.yaml` entries count as known.
660    #[must_use]
661    pub fn is_known_model(&self, identifier: &str) -> bool {
662        self.get_model_spec(identifier).is_some()
663    }
664
665    /// Returns the non-legacy API identifiers for a provider, sorted.
666    ///
667    /// Intended for error messages that need to show the user what they could
668    /// have typed. Legacy models are omitted: they still resolve, but should
669    /// not be suggested.
670    #[must_use]
671    pub fn known_identifiers(&self, provider: &str) -> Vec<&str> {
672        let mut identifiers: Vec<&str> = self
673            .get_models_by_provider(provider)
674            .into_iter()
675            .filter(|model| !model.legacy)
676            .map(|model| model.api_identifier.as_str())
677            .collect();
678        identifiers.sort_unstable();
679        identifiers
680    }
681
682    /// Returns models filtered by provider and tier.
683    #[must_use]
684    pub fn get_models_by_provider_and_tier(&self, provider: &str, tier: &str) -> Vec<&ModelSpec> {
685        self.get_models_by_provider(provider)
686            .into_iter()
687            .filter(|model| model.tier == tier)
688            .collect()
689    }
690
691    /// Returns the default model identifier for a provider, as defined in `models.yaml`.
692    #[must_use]
693    pub fn get_default_model(&self, provider: &str) -> Option<&str> {
694        self.config
695            .providers
696            .get(provider)
697            .map(|p| p.default_model.as_str())
698    }
699
700    /// Returns the provider configuration.
701    #[must_use]
702    pub fn get_provider_config(&self, provider: &str) -> Option<&ProviderConfig> {
703        self.config.providers.get(provider)
704    }
705
706    /// Returns tier information for a provider.
707    #[must_use]
708    pub fn get_tier_info(&self, provider: &str, tier: &str) -> Option<&TierInfo> {
709        self.config.providers.get(provider)?.tiers.get(tier)
710    }
711
712    /// Returns the beta headers for a model.
713    #[must_use]
714    pub fn get_beta_headers(&self, api_identifier: &str) -> &[BetaHeader] {
715        self.get_model_spec(api_identifier)
716            .map(|spec| spec.beta_headers.as_slice())
717            .unwrap_or_default()
718    }
719
720    /// Returns the max output tokens for a model with a specific beta header active.
721    #[must_use]
722    pub fn get_max_output_tokens_with_beta(&self, api_identifier: &str, beta_value: &str) -> usize {
723        if let Some(spec) = self.get_model_spec(api_identifier) {
724            if let Some(bh) = spec.beta_headers.iter().find(|b| b.value == beta_value) {
725                if let Some(max) = bh.max_output_tokens {
726                    return max;
727                }
728            }
729            return spec.max_output_tokens;
730        }
731        self.get_max_output_tokens(api_identifier)
732    }
733
734    /// Returns the input context for a model with a specific beta header active.
735    #[must_use]
736    pub fn get_input_context_with_beta(&self, api_identifier: &str, beta_value: &str) -> usize {
737        if let Some(spec) = self.get_model_spec(api_identifier) {
738            if let Some(bh) = spec.beta_headers.iter().find(|b| b.value == beta_value) {
739                if let Some(ctx) = bh.input_context {
740                    return ctx;
741                }
742            }
743            return spec.input_context;
744        }
745        self.get_input_context(api_identifier)
746    }
747
748    /// Classifies a raw model identifier into its [`ModelFamily`].
749    ///
750    /// Resolves through [`Self::get_model_spec`] first, so Bedrock/region-
751    /// prefixed and versioned identifiers normalize the same way every other
752    /// lookup on this registry does; the family word (Fable/Opus/Sonnet/
753    /// Haiku) is then matched, case-insensitively, against the resolved
754    /// spec's human-readable [`ModelSpec::model`] name. A registry miss
755    /// falls back to matching directly against the raw identifier, so a
756    /// brand-new model not yet in the catalog still classifies correctly as
757    /// long as its id contains the family word. No match on either β‡’
758    /// [`ModelFamily::Unknown`].
759    #[must_use]
760    pub fn get_model_family(&self, api_identifier: &str) -> ModelFamily {
761        let haystack = self
762            .get_model_spec(api_identifier)
763            .map_or(api_identifier, |spec| spec.model.as_str())
764            .to_ascii_lowercase();
765        if haystack.contains("fable") {
766            ModelFamily::Fable
767        } else if haystack.contains("opus") {
768            ModelFamily::Opus
769        } else if haystack.contains("sonnet") {
770            ModelFamily::Sonnet
771        } else if haystack.contains("haiku") {
772            ModelFamily::Haiku
773        } else {
774            ModelFamily::Unknown
775        }
776    }
777}
778
779/// Returns `true` for OpenAI reasoning-series identifiers: a leading `o`
780/// immediately followed by a digit (`o1-mini`, `o3`, `o4-mini`, and any
781/// future `o<N>` variant). Kept separate from the `gpt`/`chatgpt` prefixes so
782/// the reasoning family is matched without also swallowing unrelated
783/// identifiers that merely begin with `o`.
784fn is_openai_reasoning_id(id: &str) -> bool {
785    let mut chars = id.chars();
786    chars.next() == Some('o') && chars.next().is_some_and(|c| c.is_ascii_digit())
787}
788
789/// Default project-local catalog path: `<cwd>/.omni-dev/models.yaml`.
790fn default_project_path() -> Option<PathBuf> {
791    std::env::current_dir()
792        .ok()
793        .map(|cwd| cwd.join(".omni-dev").join("models.yaml"))
794}
795
796/// Default user-level catalog path: `~/.omni-dev/models.yaml`.
797fn default_user_path() -> Option<PathBuf> {
798    dirs::home_dir().map(|h| h.join(".omni-dev").join("models.yaml"))
799}
800
801/// Reads `path` if it exists. Returns `None` for missing files; logs and
802/// returns `None` for read errors so the caller can fall through.
803fn read_optional_yaml(path: &Path) -> Option<String> {
804    if !path.exists() {
805        return None;
806    }
807    match std::fs::read_to_string(path) {
808        Ok(s) => Some(s),
809        Err(e) => {
810            tracing::error!(
811                "Failed to read {}: {e}. Falling through to lower-precedence layers.",
812                path.display()
813            );
814            None
815        }
816    }
817}
818
819/// Merges a single layer's parsed YAML value into the accumulator.
820///
821/// The structure is treated specially at two top-level keys:
822/// - `models`: a sequence merged by `api_identifier`. Existing entries are
823///   deep-merged with the incoming entry; new entries are appended.
824/// - `providers`: a mapping deep-merged per provider name (so a user file
825///   can override e.g. `default_model` on the embedded `claude` provider
826///   without having to re-declare every tier).
827///
828/// All other top-level keys (such as `version`) are last-writer-wins.
829fn merge_layer_into(
830    dest: &mut serde_yaml::Value,
831    src: serde_yaml::Value,
832    source: ModelSource,
833    model_sources: &mut HashMap<String, ModelSource>,
834    provider_sources: &mut HashMap<String, ModelSource>,
835) {
836    use serde_yaml::Value;
837
838    let Value::Mapping(src_map) = src else {
839        // Top-level isn't a mapping β€” treat the layer as a wholesale
840        // replacement. (The embedded YAML is well-formed, so this is only
841        // exercised by adversarial user input.)
842        *dest = src;
843        return;
844    };
845
846    if !matches!(dest, Value::Mapping(_)) {
847        *dest = Value::Mapping(serde_yaml::Mapping::new());
848    }
849    let Value::Mapping(dest_map) = dest else {
850        unreachable!("dest is a mapping after the check above");
851    };
852
853    for (k, v) in src_map {
854        match k.as_str() {
855            Some("models") => merge_models_into(dest_map, k, v, source, model_sources),
856            Some("providers") => merge_providers_into(dest_map, k, v, source, provider_sources),
857            _ => {
858                dest_map.insert(k, v);
859            }
860        }
861    }
862}
863
864fn merge_models_into(
865    dest_map: &mut serde_yaml::Mapping,
866    key: serde_yaml::Value,
867    incoming: serde_yaml::Value,
868    source: ModelSource,
869    model_sources: &mut HashMap<String, ModelSource>,
870) {
871    use serde_yaml::Value;
872
873    let Value::Sequence(incoming_seq) = incoming else {
874        // Not a sequence β€” replace whatever is there.
875        dest_map.insert(key, incoming);
876        return;
877    };
878
879    let dest_value = dest_map
880        .entry(key)
881        .or_insert_with(|| Value::Sequence(Vec::new()));
882    if !matches!(dest_value, Value::Sequence(_)) {
883        *dest_value = Value::Sequence(Vec::new());
884    }
885    let Value::Sequence(dest_seq) = dest_value else {
886        unreachable!("dest is a sequence after the check above");
887    };
888
889    for entry in incoming_seq {
890        let api_id = entry
891            .get("api_identifier")
892            .and_then(|v| v.as_str())
893            .map(String::from);
894
895        let Some(api_id) = api_id else {
896            tracing::warn!(
897                "Skipping model entry without `api_identifier` from {source} models.yaml"
898            );
899            continue;
900        };
901
902        if let Some(existing) = dest_seq
903            .iter_mut()
904            .find(|e| e.get("api_identifier").and_then(serde_yaml::Value::as_str) == Some(&api_id))
905        {
906            deep_merge(existing, entry);
907        } else {
908            dest_seq.push(entry);
909        }
910
911        model_sources.insert(api_id, source);
912    }
913}
914
915fn merge_providers_into(
916    dest_map: &mut serde_yaml::Mapping,
917    key: serde_yaml::Value,
918    incoming: serde_yaml::Value,
919    source: ModelSource,
920    provider_sources: &mut HashMap<String, ModelSource>,
921) {
922    use serde_yaml::Value;
923
924    let Value::Mapping(incoming_providers) = incoming else {
925        dest_map.insert(key, incoming);
926        return;
927    };
928
929    let dest_value = dest_map
930        .entry(key)
931        .or_insert_with(|| Value::Mapping(serde_yaml::Mapping::new()));
932    if !matches!(dest_value, Value::Mapping(_)) {
933        *dest_value = Value::Mapping(serde_yaml::Mapping::new());
934    }
935    let Value::Mapping(dest_providers) = dest_value else {
936        unreachable!("dest is a mapping after the check above");
937    };
938
939    for (pname, pvalue) in incoming_providers {
940        let pname_str = pname.as_str().map(String::from);
941
942        if let Some(existing) = dest_providers.get_mut(&pname) {
943            deep_merge(existing, pvalue);
944        } else {
945            dest_providers.insert(pname.clone(), pvalue);
946        }
947
948        if let Some(name) = pname_str {
949            provider_sources.insert(name, source);
950        }
951    }
952}
953
954/// Recursive deep-merge: mappings are merged key-by-key, sequences and
955/// scalars are replaced wholesale.
956fn deep_merge(dest: &mut serde_yaml::Value, src: serde_yaml::Value) {
957    use serde_yaml::Value;
958    match (dest, src) {
959        (Value::Mapping(d), Value::Mapping(s)) => {
960            for (k, v) in s {
961                if let Some(existing) = d.get_mut(&k) {
962                    deep_merge(existing, v);
963                } else {
964                    d.insert(k, v);
965                }
966            }
967        }
968        (d, s) => *d = s,
969    }
970}
971
972/// Logs a warning for each user-side layer whose `version` field differs
973/// from the schema version this build understands.
974fn warn_on_version_mismatch(declared: &[(ModelSource, Option<String>)]) {
975    for (source, version) in declared {
976        if matches!(source, ModelSource::Embedded) {
977            continue;
978        }
979        match version {
980            None => {
981                tracing::warn!(
982                    "{source} models.yaml has no `version:` field; assuming compatibility with schema version {MODELS_SCHEMA_VERSION}. Add `version: \"{MODELS_SCHEMA_VERSION}\"` to silence this warning."
983                );
984            }
985            Some(v) if v == MODELS_SCHEMA_VERSION => {}
986            Some(v) => {
987                tracing::warn!(
988                    "{source} models.yaml declares schema version {v}; this build understands {MODELS_SCHEMA_VERSION}. Continuing β€” unrecognised fields may be ignored."
989                );
990            }
991        }
992    }
993}
994
995/// Global model registry instance.
996static MODEL_REGISTRY: OnceLock<ModelRegistry> = OnceLock::new();
997
998/// Returns the global model registry instance.
999#[must_use]
1000pub fn get_model_registry() -> &'static ModelRegistry {
1001    #[allow(clippy::expect_used)] // YAML is embedded via include_str! at compile time
1002    MODEL_REGISTRY.get_or_init(|| ModelRegistry::load().expect("Failed to load model registry"))
1003}
1004
1005#[cfg(test)]
1006#[allow(clippy::unwrap_used, clippy::expect_used)]
1007mod tests {
1008    use super::*;
1009    use std::io::Write;
1010
1011    fn embedded_only() -> ModelRegistry {
1012        ModelRegistry::load_layered_from_paths(None, None, None).unwrap()
1013    }
1014
1015    /// Issue #1333: `--model claude-sonnet-4-8` reached the API and 404'd
1016    /// because nothing checked the identifier against the catalog first.
1017    #[test]
1018    fn is_known_model_rejects_unknown_claude_identifier() {
1019        let registry = embedded_only();
1020        assert!(!registry.is_known_model("claude-sonnet-4-8"));
1021        assert!(registry.is_known_model("claude-sonnet-4-6"));
1022    }
1023
1024    #[test]
1025    fn is_known_model_normalizes_bedrock_identifiers() {
1026        let registry = embedded_only();
1027        // A Bedrock-style id must validate via the same normalisation
1028        // `get_model_spec` applies, not a raw map hit.
1029        let bedrock_id = registry
1030            .get_all_models()
1031            .iter()
1032            .find(|m| m.provider == "claude")
1033            .map(|m| format!("us.anthropic.{}", m.api_identifier))
1034            .expect("embedded catalog should list claude models");
1035        assert!(
1036            registry.is_known_model(&bedrock_id),
1037            "{bedrock_id} should normalise to a known model"
1038        );
1039    }
1040
1041    #[test]
1042    fn known_identifiers_lists_non_legacy_claude_models() {
1043        let registry = embedded_only();
1044        let identifiers = registry.known_identifiers("claude");
1045
1046        assert!(!identifiers.is_empty());
1047        assert!(identifiers.contains(&"claude-sonnet-4-6"));
1048        assert!(
1049            identifiers.windows(2).all(|w| w[0] <= w[1]),
1050            "identifiers should be sorted: {identifiers:?}"
1051        );
1052        for id in &identifiers {
1053            let spec = registry
1054                .get_model_spec(id)
1055                .expect("listed model must exist");
1056            assert!(!spec.legacy, "legacy model {id} should not be suggested");
1057            assert_eq!(spec.provider, "claude");
1058        }
1059    }
1060
1061    #[test]
1062    fn known_identifiers_is_empty_for_provider_without_models() {
1063        let registry = embedded_only();
1064        assert!(registry.known_identifiers("ollama").is_empty());
1065    }
1066
1067    /// Legacy models are omitted from suggestions but must still validate β€”
1068    /// preflight would otherwise reject a model the user can legitimately run.
1069    #[test]
1070    fn legacy_models_are_known_but_not_suggested() {
1071        let registry = embedded_only();
1072        let legacy = registry
1073            .get_all_models()
1074            .iter()
1075            .find(|m| m.provider == "claude" && m.legacy)
1076            .map(|m| m.api_identifier.clone())
1077            .expect("embedded catalog should list a legacy claude model");
1078
1079        assert!(
1080            registry.is_known_model(&legacy),
1081            "{legacy} must still resolve"
1082        );
1083        assert!(
1084            !registry
1085                .known_identifiers("claude")
1086                .contains(&legacy.as_str()),
1087            "{legacy} should not be suggested"
1088        );
1089    }
1090
1091    fn write_yaml(dir: &Path, name: &str, contents: &str) -> PathBuf {
1092        let path = dir.join(name);
1093        let mut f = std::fs::File::create(&path).unwrap();
1094        f.write_all(contents.as_bytes()).unwrap();
1095        path
1096    }
1097
1098    #[test]
1099    fn load_model_registry() {
1100        let registry = embedded_only();
1101        assert!(!registry.config.models.is_empty());
1102        assert!(registry.config.providers.contains_key("claude"));
1103        assert_eq!(
1104            registry.config.version.as_deref(),
1105            Some(MODELS_SCHEMA_VERSION)
1106        );
1107    }
1108
1109    #[test]
1110    fn claude_model_lookup() {
1111        let registry = embedded_only();
1112
1113        // Test legacy Claude 3 Opus
1114        let opus_spec = registry.get_model_spec("claude-3-opus-20240229");
1115        assert!(opus_spec.is_some());
1116        assert_eq!(opus_spec.unwrap().max_output_tokens, 4096);
1117        assert_eq!(opus_spec.unwrap().provider, "claude");
1118        assert!(registry.is_legacy_model("claude-3-opus-20240229"));
1119
1120        // Test Claude 4.5 Sonnet (current generation)
1121        let sonnet45_tokens = registry.get_max_output_tokens("claude-sonnet-4-5-20250929");
1122        assert_eq!(sonnet45_tokens, 64000);
1123
1124        // Test legacy Claude 4 Sonnet
1125        let sonnet4_tokens = registry.get_max_output_tokens("claude-sonnet-4-20250514");
1126        assert_eq!(sonnet4_tokens, 64000);
1127        assert!(registry.is_legacy_model("claude-sonnet-4-20250514"));
1128
1129        // Test unknown model falls back to provider defaults
1130        let unknown_tokens = registry.get_max_output_tokens("claude-unknown-model");
1131        assert_eq!(unknown_tokens, 4096); // Should use Claude provider defaults
1132    }
1133
1134    #[test]
1135    fn unknown_provider_uses_ultimate_fallback() {
1136        let registry = embedded_only();
1137
1138        // Unknown identifier with no recognisable provider β†’ ultimate fallback.
1139        assert_eq!(
1140            registry.get_max_output_tokens("totally-unknown-vendor-x"),
1141            FALLBACK_MAX_OUTPUT_TOKENS
1142        );
1143        assert_eq!(
1144            registry.get_input_context("totally-unknown-vendor-x"),
1145            FALLBACK_INPUT_CONTEXT
1146        );
1147    }
1148
1149    #[test]
1150    fn unknown_openai_model_uses_openai_provider_defaults() {
1151        let registry = embedded_only();
1152
1153        // Brand-new OpenAI identifiers the embedded catalog has not caught up
1154        // to yet resolve to the `openai` provider defaults (16384/128000), not
1155        // the ultimate hard-coded fallback. Covers the gpt-, chatgpt-, and
1156        // o<N>-reasoning identifier shapes.
1157        for id in ["gpt-6-ultra", "chatgpt-6-latest", "o5-preview"] {
1158            assert_eq!(
1159                registry.get_max_output_tokens(id),
1160                16384,
1161                "max_output_tokens for {id}"
1162            );
1163            assert_eq!(
1164                registry.get_input_context(id),
1165                128_000,
1166                "input_context for {id}"
1167            );
1168        }
1169    }
1170
1171    #[test]
1172    fn unknown_gemini_model_uses_gemini_provider_defaults() {
1173        let registry = embedded_only();
1174
1175        // Unknown Gemini identifier resolves to the `gemini` provider defaults
1176        // (8192/1048576) rather than the ultimate hard-coded fallback.
1177        let id = "gemini-9-pro";
1178        assert_eq!(registry.get_max_output_tokens(id), 8192);
1179        assert_eq!(registry.get_input_context(id), 1_048_576);
1180    }
1181
1182    #[test]
1183    fn provider_filtering() {
1184        let registry = embedded_only();
1185
1186        let claude_models = registry.get_models_by_provider("claude");
1187        assert!(!claude_models.is_empty());
1188
1189        let fast_claude_models = registry.get_models_by_provider_and_tier("claude", "fast");
1190        assert!(!fast_claude_models.is_empty());
1191
1192        let tier_info = registry.get_tier_info("claude", "fast");
1193        assert!(tier_info.is_some());
1194    }
1195
1196    #[test]
1197    fn provider_config() {
1198        let registry = embedded_only();
1199
1200        let claude_config = registry.get_provider_config("claude");
1201        assert!(claude_config.is_some());
1202        assert_eq!(claude_config.unwrap().name, "Anthropic Claude");
1203    }
1204
1205    #[test]
1206    fn default_model_per_provider() {
1207        let registry = embedded_only();
1208
1209        assert_eq!(
1210            registry.get_default_model("claude"),
1211            Some("claude-sonnet-5")
1212        );
1213        assert_eq!(registry.get_default_model("openai"), Some("gpt-5-mini"));
1214        assert_eq!(
1215            registry.get_default_model("gemini"),
1216            Some("gemini-2.5-flash")
1217        );
1218        assert_eq!(registry.get_default_model("nonexistent"), None);
1219    }
1220
1221    #[test]
1222    fn normalized_id_matching() {
1223        let registry = embedded_only();
1224
1225        // Test Bedrock-style identifiers
1226        let bedrock_3_7_sonnet = "us.anthropic.claude-3-7-sonnet-20250219-v1:0";
1227        let spec = registry.get_model_spec(bedrock_3_7_sonnet);
1228        assert!(spec.is_some());
1229        assert_eq!(spec.unwrap().api_identifier, "claude-3-7-sonnet-20250219");
1230        assert_eq!(spec.unwrap().max_output_tokens, 64000);
1231
1232        // Test AWS-style identifiers
1233        let aws_haiku = "anthropic.claude-3-haiku-20240307-v1:0";
1234        let spec = registry.get_model_spec(aws_haiku);
1235        assert!(spec.is_some());
1236        assert_eq!(spec.unwrap().api_identifier, "claude-3-haiku-20240307");
1237        assert_eq!(spec.unwrap().max_output_tokens, 4096);
1238
1239        // Test European region
1240        let eu_opus = "eu.anthropic.claude-3-opus-20240229-v2:1";
1241        let spec = registry.get_model_spec(eu_opus);
1242        assert!(spec.is_some());
1243        assert_eq!(spec.unwrap().api_identifier, "claude-3-opus-20240229");
1244        assert_eq!(spec.unwrap().max_output_tokens, 4096);
1245
1246        // Test exact match still works for Claude 4.5 Sonnet
1247        let exact_sonnet45 = "claude-sonnet-4-5-20250929";
1248        let spec = registry.get_model_spec(exact_sonnet45);
1249        assert!(spec.is_some());
1250        assert_eq!(spec.unwrap().max_output_tokens, 64000);
1251
1252        // Test legacy Claude 4 Sonnet
1253        let exact_sonnet4 = "claude-sonnet-4-20250514";
1254        let spec = registry.get_model_spec(exact_sonnet4);
1255        assert!(spec.is_some());
1256        assert_eq!(spec.unwrap().max_output_tokens, 64000);
1257    }
1258
1259    /// Structured-output support is flagged per-model in the catalog (#1119):
1260    /// recent models (the default `claude-sonnet-4-6` included) advertise it,
1261    /// while older models and unknown identifiers do not β€” so the schema path
1262    /// never risks a `400` on a model that can't honour `output_config`.
1263    #[test]
1264    fn supports_structured_output_gates_by_model() {
1265        let registry = embedded_only();
1266
1267        // Flagged models β€” including the default and a Bedrock-style prefix.
1268        assert!(registry.supports_structured_output("claude-fable-5"));
1269        assert!(registry.supports_structured_output("claude-opus-4-8"));
1270        assert!(registry.supports_structured_output("claude-opus-4-7"));
1271        assert!(registry.supports_structured_output("claude-sonnet-5"));
1272        assert!(registry.supports_structured_output("claude-sonnet-4-6"));
1273        assert!(registry.supports_structured_output("claude-opus-4-6"));
1274        assert!(registry.supports_structured_output("claude-haiku-4-5-20251001"));
1275        assert!(registry.supports_structured_output("claude-opus-4-5-20251101"));
1276        assert!(
1277            registry.supports_structured_output("us.anthropic.claude-sonnet-4-5-20250929-v1:0"),
1278            "Bedrock/region-prefixed forms must normalize to the flagged spec"
1279        );
1280
1281        // Older models that would 400 on output_config keep the YAML path.
1282        assert!(!registry.supports_structured_output("claude-opus-4-1-20250805"));
1283        assert!(!registry.supports_structured_output("claude-3-opus-20240229"));
1284        assert!(!registry.supports_structured_output("claude-sonnet-4-20250514"));
1285
1286        // Unknown identifiers are conservatively unsupported.
1287        assert!(!registry.supports_structured_output("totally-unknown-model"));
1288    }
1289
1290    /// The current-generation Claude models carry a 1M context window natively,
1291    /// at standard pricing with no long-context premium β€” no beta header
1292    /// involved (#1334).
1293    #[test]
1294    fn current_generation_models_are_registered() {
1295        let registry = embedded_only();
1296
1297        for id in [
1298            "claude-fable-5",
1299            "claude-opus-4-8",
1300            "claude-opus-4-7",
1301            "claude-sonnet-5",
1302        ] {
1303            let spec = registry
1304                .get_model_spec(id)
1305                .unwrap_or_else(|| panic!("{id} must be registered"));
1306            assert_eq!(spec.input_context, 1_000_000, "{id} context window");
1307            assert_eq!(spec.max_output_tokens, 128_000, "{id} max output");
1308            assert!(
1309                spec.beta_headers.is_empty(),
1310                "{id} exposes 1M natively and must not rely on a context beta header"
1311            );
1312            assert!(spec.input_token_price.is_some(), "{id} input price");
1313            assert!(spec.output_token_price.is_some(), "{id} output price");
1314            assert!(!spec.legacy, "{id} is current, not legacy");
1315        }
1316
1317        // Sonnet 5 records the sticker rate; the schema cannot express the
1318        // time-bounded introductory price ($2/$10 through 2026-08-31).
1319        let sonnet5 = registry.get_model_spec("claude-sonnet-5").unwrap();
1320        assert_eq!(sonnet5.input_token_price, Some(3.0));
1321        assert_eq!(sonnet5.output_token_price, Some(15.0));
1322    }
1323
1324    /// The 4.6 generation also exposes 1M context natively. These values were
1325    /// stale (200k + a beta header, and a 64k output cap on Sonnet 4.6) β€” see
1326    /// #1334.
1327    #[test]
1328    fn claude_4_6_limits_are_native_not_beta_gated() {
1329        let registry = embedded_only();
1330
1331        assert_eq!(registry.get_input_context("claude-opus-4-6"), 1_000_000);
1332        assert_eq!(registry.get_input_context("claude-sonnet-4-6"), 1_000_000);
1333        assert_eq!(registry.get_max_output_tokens("claude-sonnet-4-6"), 128_000);
1334        assert_eq!(registry.get_max_output_tokens("claude-opus-4-6"), 128_000);
1335    }
1336
1337    /// Anthropic's undated aliases must each have their own entry: identifier
1338    /// resolution is exact-match plus a narrow normalization that never maps an
1339    /// alias onto its dated form. Without an entry the lookup misses and the
1340    /// caller silently gets the provider fallback (4096 output tokens) instead
1341    /// of the model's real limits β€” which is what `ANTHROPIC_DEFAULT_HAIKU_MODEL`
1342    /// defaulting to `claude-haiku-4-5` used to do (#1334).
1343    #[test]
1344    fn undated_aliases_resolve_to_real_limits() {
1345        let registry = embedded_only();
1346
1347        for (alias, dated) in [
1348            ("claude-haiku-4-5", "claude-haiku-4-5-20251001"),
1349            ("claude-sonnet-4-5", "claude-sonnet-4-5-20250929"),
1350            ("claude-opus-4-5", "claude-opus-4-5-20251101"),
1351        ] {
1352            assert_eq!(
1353                registry.get_max_output_tokens(alias),
1354                registry.get_max_output_tokens(dated),
1355                "{alias} must resolve to the same output cap as {dated}, \
1356                 not the provider fallback"
1357            );
1358            assert_eq!(
1359                registry.get_input_context(alias),
1360                registry.get_input_context(dated),
1361                "{alias} context window must match {dated}"
1362            );
1363        }
1364
1365        // The specific regression: the fallback would have been 4096.
1366        assert_eq!(registry.get_max_output_tokens("claude-haiku-4-5"), 64_000);
1367    }
1368
1369    /// `retires` records an *announced* date only. Absence means "no published
1370    /// date" β€” never "not retiring" β€” which is why deprecated-but-undated models
1371    /// leave it unset (#1334).
1372    #[test]
1373    fn retires_records_announced_dates_only() {
1374        let registry = embedded_only();
1375
1376        let opus_41 = registry.get_model_spec("claude-opus-4-1-20250805").unwrap();
1377        assert_eq!(opus_41.retires.as_deref(), Some("2026-08-05"));
1378        assert!(opus_41.legacy);
1379
1380        // Retired models keep a past date; they are retained purely so Bedrock
1381        // identifier normalization still resolves them (ADR-0011).
1382        assert_eq!(
1383            registry
1384                .get_model_spec("claude-3-opus-20240229")
1385                .unwrap()
1386                .retires
1387                .as_deref(),
1388            Some("2026-01-05")
1389        );
1390
1391        // Deprecated with retirement still TBD β€” no date invented.
1392        for id in ["claude-opus-4-20250514", "claude-sonnet-4-20250514"] {
1393            let spec = registry.get_model_spec(id).unwrap();
1394            assert!(spec.legacy, "{id} is deprecated");
1395            assert!(
1396                spec.retires.is_none(),
1397                "{id} has no announced retirement date; absence must not be \
1398                 filled in with a guess"
1399            );
1400        }
1401
1402        // Current models are not retiring.
1403        assert!(registry
1404            .get_model_spec("claude-sonnet-5")
1405            .unwrap()
1406            .retires
1407            .is_none());
1408    }
1409
1410    #[test]
1411    fn extract_core_model_identifier() {
1412        let registry = embedded_only();
1413
1414        // Test various formats
1415        assert_eq!(
1416            registry.extract_core_model_identifier("us.anthropic.claude-3-7-sonnet-20250219-v1:0"),
1417            "claude-3-7-sonnet-20250219"
1418        );
1419
1420        assert_eq!(
1421            registry.extract_core_model_identifier("anthropic.claude-3-haiku-20240307-v1:0"),
1422            "claude-3-haiku-20240307"
1423        );
1424
1425        assert_eq!(
1426            registry.extract_core_model_identifier("claude-3-opus-20240229"),
1427            "claude-3-opus-20240229"
1428        );
1429
1430        assert_eq!(
1431            registry.extract_core_model_identifier("eu.anthropic.claude-sonnet-4-20250514-v2:1"),
1432            "claude-sonnet-4-20250514"
1433        );
1434    }
1435
1436    #[test]
1437    fn beta_header_lookups() {
1438        let registry = embedded_only();
1439
1440        // Opus 4.6 base limits β€” 1M context is native on this generation, so
1441        // the base already grants what the context beta used to unlock (#1334).
1442        assert_eq!(registry.get_max_output_tokens("claude-opus-4-6"), 128_000);
1443        assert_eq!(registry.get_input_context("claude-opus-4-6"), 1_000_000);
1444
1445        // The context beta is now a no-op for limits: it resolves to the same
1446        // 1M the base grants. The entry is retained only so that
1447        // `validate_beta_header` keeps accepting the flag from callers who
1448        // still pass it β€” hence it must still resolve, not vanish.
1449        assert_eq!(
1450            registry.get_input_context_with_beta("claude-opus-4-6", "context-1m-2025-08-07"),
1451            1_000_000
1452        );
1453        // max_output_tokens unchanged with context beta
1454        assert_eq!(
1455            registry.get_max_output_tokens_with_beta("claude-opus-4-6", "context-1m-2025-08-07"),
1456            128_000
1457        );
1458
1459        // Sonnet 3.7 with output-128k beta
1460        assert_eq!(
1461            registry.get_max_output_tokens_with_beta(
1462                "claude-3-7-sonnet-20250219",
1463                "output-128k-2025-02-19"
1464            ),
1465            128_000
1466        );
1467
1468        // Sonnet 3.7 base max_output_tokens without beta
1469        assert_eq!(
1470            registry.get_max_output_tokens("claude-3-7-sonnet-20250219"),
1471            64000
1472        );
1473
1474        // Beta headers accessor
1475        let headers = registry.get_beta_headers("claude-opus-4-6");
1476        assert_eq!(headers.len(), 1);
1477        assert_eq!(headers[0].key, "anthropic-beta");
1478        assert_eq!(headers[0].value, "context-1m-2025-08-07");
1479
1480        // Sonnet 3.7 has two beta headers
1481        let headers = registry.get_beta_headers("claude-3-7-sonnet-20250219");
1482        assert_eq!(headers.len(), 2);
1483
1484        // Model without beta headers returns empty slice
1485        let headers = registry.get_beta_headers("claude-3-haiku-20240307");
1486        assert!(headers.is_empty());
1487
1488        // Unknown model returns empty slice
1489        let headers = registry.get_beta_headers("unknown-model");
1490        assert!(headers.is_empty());
1491    }
1492
1493    #[test]
1494    fn beta_lookups_for_unknown_model_fall_through_to_provider_defaults() {
1495        let registry = embedded_only();
1496
1497        // Unknown model with arbitrary beta value: get_max_output_tokens_with_beta
1498        // and get_input_context_with_beta should both delegate to the no-beta
1499        // resolver, which in turn returns provider defaults for "claude-…".
1500        assert_eq!(
1501            registry
1502                .get_max_output_tokens_with_beta("claude-unknown-model", "context-1m-2025-08-07"),
1503            4096
1504        );
1505        assert_eq!(
1506            registry.get_input_context_with_beta("claude-unknown-model", "context-1m-2025-08-07"),
1507            200_000
1508        );
1509    }
1510
1511    #[test]
1512    fn embedded_models_default_to_embedded_source() {
1513        let registry = embedded_only();
1514        let spec = registry.get_model_spec("claude-opus-4-6").unwrap();
1515        assert_eq!(spec.source, ModelSource::Embedded);
1516
1517        let provider = registry.get_provider_config("claude").unwrap();
1518        assert_eq!(provider.source, ModelSource::Embedded);
1519    }
1520
1521    #[test]
1522    fn missing_user_and_project_files_fall_through_silently() {
1523        let dir = tempfile::tempdir().unwrap();
1524        let project_path = dir.path().join("missing-project.yaml");
1525        let user_path = dir.path().join("missing-user.yaml");
1526        let registry =
1527            ModelRegistry::load_layered_from_paths(Some(&project_path), Some(&user_path), None)
1528                .unwrap();
1529
1530        // Behaviour identical to embedded-only.
1531        let spec = registry.get_model_spec("claude-opus-4-6").unwrap();
1532        assert_eq!(spec.source, ModelSource::Embedded);
1533        assert_eq!(spec.max_output_tokens, 128_000);
1534    }
1535
1536    #[test]
1537    fn user_layer_overrides_embedded_entry() {
1538        let dir = tempfile::tempdir().unwrap();
1539        let user = write_yaml(
1540            dir.path(),
1541            "user.yaml",
1542            r#"
1543version: "1"
1544models:
1545  - provider: "claude"
1546    model: "Claude Opus 4.6 (custom)"
1547    api_identifier: "claude-opus-4-6"
1548    max_output_tokens: 999999
1549    input_context: 200000
1550    generation: 4.6
1551    tier: "flagship"
1552"#,
1553        );
1554
1555        let registry = ModelRegistry::load_layered_from_paths(None, Some(&user), None).unwrap();
1556        let spec = registry.get_model_spec("claude-opus-4-6").unwrap();
1557        assert_eq!(spec.max_output_tokens, 999_999);
1558        assert_eq!(spec.model, "Claude Opus 4.6 (custom)");
1559        assert_eq!(spec.source, ModelSource::User);
1560    }
1561
1562    #[test]
1563    fn project_layer_takes_precedence_over_user_layer() {
1564        let dir = tempfile::tempdir().unwrap();
1565        let user = write_yaml(
1566            dir.path(),
1567            "user.yaml",
1568            r#"
1569version: "1"
1570models:
1571  - provider: "claude"
1572    model: "From User"
1573    api_identifier: "claude-opus-4-6"
1574    max_output_tokens: 1
1575    input_context: 1
1576    generation: 4.6
1577    tier: "flagship"
1578"#,
1579        );
1580        let project = write_yaml(
1581            dir.path(),
1582            "project.yaml",
1583            r#"
1584version: "1"
1585models:
1586  - provider: "claude"
1587    model: "From Project"
1588    api_identifier: "claude-opus-4-6"
1589    max_output_tokens: 2
1590    input_context: 2
1591    generation: 4.6
1592    tier: "flagship"
1593"#,
1594        );
1595
1596        let registry =
1597            ModelRegistry::load_layered_from_paths(Some(&project), Some(&user), None).unwrap();
1598        let spec = registry.get_model_spec("claude-opus-4-6").unwrap();
1599        assert_eq!(spec.model, "From Project");
1600        assert_eq!(spec.max_output_tokens, 2);
1601        assert_eq!(spec.source, ModelSource::Project);
1602    }
1603
1604    #[test]
1605    fn additive_user_entry_is_appended() {
1606        let dir = tempfile::tempdir().unwrap();
1607        let user = write_yaml(
1608            dir.path(),
1609            "user.yaml",
1610            r#"
1611version: "1"
1612models:
1613  - provider: "claude"
1614    model: "Claude Custom Future"
1615    api_identifier: "claude-future-9000"
1616    max_output_tokens: 250000
1617    input_context: 5000000
1618    generation: 9.0
1619    tier: "flagship"
1620"#,
1621        );
1622
1623        let registry = ModelRegistry::load_layered_from_paths(None, Some(&user), None).unwrap();
1624        let spec = registry.get_model_spec("claude-future-9000").unwrap();
1625        assert_eq!(spec.max_output_tokens, 250_000);
1626        assert_eq!(spec.input_context, 5_000_000);
1627        assert_eq!(spec.source, ModelSource::User);
1628
1629        // And a pre-existing model is still present, sourced from embedded.
1630        let opus = registry.get_model_spec("claude-opus-4-6").unwrap();
1631        assert_eq!(opus.source, ModelSource::Embedded);
1632    }
1633
1634    #[test]
1635    fn provider_fields_can_be_partially_overridden() {
1636        let dir = tempfile::tempdir().unwrap();
1637        // User only changes claude.default_model. Other fields (tiers,
1638        // defaults, api_base, name) must be preserved from the embedded layer.
1639        let user = write_yaml(
1640            dir.path(),
1641            "user.yaml",
1642            r#"
1643version: "1"
1644providers:
1645  claude:
1646    default_model: "claude-opus-4-6"
1647"#,
1648        );
1649
1650        let registry = ModelRegistry::load_layered_from_paths(None, Some(&user), None).unwrap();
1651        let claude = registry.get_provider_config("claude").unwrap();
1652        assert_eq!(claude.default_model, "claude-opus-4-6");
1653        // Embedded fields must survive the partial override.
1654        assert_eq!(claude.name, "Anthropic Claude");
1655        assert_eq!(claude.api_base, "https://api.anthropic.com/v1");
1656        assert!(claude.tiers.contains_key("flagship"));
1657        // Provider source reflects the most-recent contributing layer.
1658        assert_eq!(claude.source, ModelSource::User);
1659    }
1660
1661    #[test]
1662    fn malformed_user_yaml_logs_and_falls_through() {
1663        let dir = tempfile::tempdir().unwrap();
1664        let user = write_yaml(
1665            dir.path(),
1666            "user.yaml",
1667            "this: is: definitely: not: valid: yaml: [unbalanced",
1668        );
1669
1670        let registry = ModelRegistry::load_layered_from_paths(None, Some(&user), None).unwrap();
1671        // Embedded catalog is intact.
1672        let spec = registry.get_model_spec("claude-opus-4-6").unwrap();
1673        assert_eq!(spec.source, ModelSource::Embedded);
1674        assert_eq!(spec.max_output_tokens, 128_000);
1675    }
1676
1677    #[test]
1678    fn override_path_short_circuits_user_and_project() {
1679        let dir = tempfile::tempdir().unwrap();
1680        let user = write_yaml(
1681            dir.path(),
1682            "user.yaml",
1683            r#"
1684version: "1"
1685models:
1686  - provider: "claude"
1687    model: "From User"
1688    api_identifier: "claude-opus-4-6"
1689    max_output_tokens: 1
1690    input_context: 1
1691    generation: 4.6
1692    tier: "flagship"
1693"#,
1694        );
1695        let project = write_yaml(
1696            dir.path(),
1697            "project.yaml",
1698            r#"
1699version: "1"
1700models:
1701  - provider: "claude"
1702    model: "From Project"
1703    api_identifier: "claude-opus-4-6"
1704    max_output_tokens: 2
1705    input_context: 2
1706    generation: 4.6
1707    tier: "flagship"
1708"#,
1709        );
1710        let override_file = write_yaml(
1711            dir.path(),
1712            "override.yaml",
1713            r#"
1714version: "1"
1715models:
1716  - provider: "claude"
1717    model: "From Override"
1718    api_identifier: "claude-opus-4-6"
1719    max_output_tokens: 3
1720    input_context: 3
1721    generation: 4.6
1722    tier: "flagship"
1723"#,
1724        );
1725
1726        let registry = ModelRegistry::load_layered_from_paths(
1727            Some(&project),
1728            Some(&user),
1729            Some(&override_file),
1730        )
1731        .unwrap();
1732        let spec = registry.get_model_spec("claude-opus-4-6").unwrap();
1733        assert_eq!(spec.model, "From Override");
1734        assert_eq!(spec.max_output_tokens, 3);
1735        assert_eq!(spec.source, ModelSource::Override);
1736    }
1737
1738    #[test]
1739    fn missing_override_path_falls_back_to_embedded() {
1740        let dir = tempfile::tempdir().unwrap();
1741        let missing = dir.path().join("does-not-exist.yaml");
1742        let registry = ModelRegistry::load_layered_from_paths(None, None, Some(&missing)).unwrap();
1743        let spec = registry.get_model_spec("claude-opus-4-6").unwrap();
1744        assert_eq!(spec.source, ModelSource::Embedded);
1745    }
1746
1747    #[test]
1748    fn version_mismatch_is_warned_not_fatal() {
1749        let dir = tempfile::tempdir().unwrap();
1750        let user = write_yaml(
1751            dir.path(),
1752            "user.yaml",
1753            r#"
1754version: "9999"
1755models:
1756  - provider: "claude"
1757    model: "From Future"
1758    api_identifier: "claude-future-9000"
1759    max_output_tokens: 1
1760    input_context: 1
1761    generation: 9.0
1762    tier: "flagship"
1763"#,
1764        );
1765        let registry = ModelRegistry::load_layered_from_paths(None, Some(&user), None).unwrap();
1766        // Loaded successfully despite version mismatch.
1767        assert!(registry.get_model_spec("claude-future-9000").is_some());
1768    }
1769
1770    #[test]
1771    fn missing_version_is_accepted() {
1772        let dir = tempfile::tempdir().unwrap();
1773        let user = write_yaml(
1774            dir.path(),
1775            "user.yaml",
1776            r#"
1777models:
1778  - provider: "claude"
1779    model: "Versionless"
1780    api_identifier: "claude-versionless"
1781    max_output_tokens: 1
1782    input_context: 1
1783    generation: 1.0
1784    tier: "flagship"
1785"#,
1786        );
1787        let registry = ModelRegistry::load_layered_from_paths(None, Some(&user), None).unwrap();
1788        assert!(registry.get_model_spec("claude-versionless").is_some());
1789    }
1790
1791    #[test]
1792    fn model_entry_without_api_identifier_is_skipped() {
1793        let dir = tempfile::tempdir().unwrap();
1794        let user = write_yaml(
1795            dir.path(),
1796            "user.yaml",
1797            r#"
1798version: "1"
1799models:
1800  - provider: "claude"
1801    model: "No Id"
1802    max_output_tokens: 1
1803    input_context: 1
1804    generation: 1.0
1805    tier: "flagship"
1806"#,
1807        );
1808        let registry = ModelRegistry::load_layered_from_paths(None, Some(&user), None).unwrap();
1809        // Registry still loads; embedded catalog unchanged.
1810        let opus = registry.get_model_spec("claude-opus-4-6").unwrap();
1811        assert_eq!(opus.source, ModelSource::Embedded);
1812    }
1813
1814    #[test]
1815    fn model_source_display() {
1816        assert_eq!(ModelSource::Embedded.to_string(), "embedded");
1817        assert_eq!(ModelSource::User.to_string(), "user");
1818        assert_eq!(ModelSource::Project.to_string(), "project");
1819        assert_eq!(ModelSource::Override.to_string(), "override");
1820    }
1821
1822    #[test]
1823    fn embedded_yaml_must_not_be_malformed() {
1824        // Sanity-check: a malformed embedded layer would be a hard error.
1825        let layers = [(ModelSource::Embedded, "::: not yaml :::".to_string())];
1826        let result = ModelRegistry::from_layers(&layers);
1827        assert!(result.is_err());
1828    }
1829
1830    #[test]
1831    fn user_layer_with_scalar_top_level_returns_error() {
1832        // Adversarial: user YAML root is a string, not a mapping. The
1833        // wholesale-replacement branch in `merge_layer_into` discards the
1834        // embedded mapping; deserialise then fails cleanly.
1835        let dir = tempfile::tempdir().unwrap();
1836        let user = write_yaml(dir.path(), "user.yaml", "\"just a string\"\n");
1837        let result = ModelRegistry::load_layered_from_paths(None, Some(&user), None);
1838        assert!(result.is_err());
1839    }
1840
1841    #[test]
1842    fn user_layer_with_non_sequence_models_returns_error() {
1843        // Adversarial: `models: 42` triggers the non-sequence branch in
1844        // `merge_models_into`, which writes the scalar through. The final
1845        // `from_value` fails because `models` must be a sequence.
1846        let dir = tempfile::tempdir().unwrap();
1847        let user = write_yaml(
1848            dir.path(),
1849            "user.yaml",
1850            r#"
1851version: "1"
1852models: 42
1853"#,
1854        );
1855        let result = ModelRegistry::load_layered_from_paths(None, Some(&user), None);
1856        assert!(result.is_err());
1857    }
1858
1859    #[test]
1860    fn user_layer_with_non_mapping_providers_returns_error() {
1861        // Adversarial: `providers: 42` triggers the non-mapping branch in
1862        // `merge_providers_into`. The final `from_value` then fails.
1863        let dir = tempfile::tempdir().unwrap();
1864        let user = write_yaml(
1865            dir.path(),
1866            "user.yaml",
1867            r#"
1868version: "1"
1869providers: 42
1870"#,
1871        );
1872        let result = ModelRegistry::load_layered_from_paths(None, Some(&user), None);
1873        assert!(result.is_err());
1874    }
1875
1876    #[test]
1877    fn deep_merge_inserts_new_keys_into_existing_mapping() {
1878        // Exercises the "key not in dest" branch of `deep_merge`. Adding a
1879        // new tier under `providers.claude.tiers` requires the merger to
1880        // *insert* (not overwrite) within an existing mapping.
1881        let dir = tempfile::tempdir().unwrap();
1882        let user = write_yaml(
1883            dir.path(),
1884            "user.yaml",
1885            r#"
1886version: "1"
1887providers:
1888  claude:
1889    tiers:
1890      experimental:
1891        description: "Experimental tier"
1892        use_cases: ["bleeding edge"]
1893"#,
1894        );
1895        let registry = ModelRegistry::load_layered_from_paths(None, Some(&user), None).unwrap();
1896        let claude = registry.get_provider_config("claude").unwrap();
1897        // Embedded tiers preserved…
1898        assert!(claude.tiers.contains_key("flagship"));
1899        assert!(claude.tiers.contains_key("balanced"));
1900        assert!(claude.tiers.contains_key("fast"));
1901        // …and the new tier was inserted.
1902        let experimental = claude.tiers.get("experimental").unwrap();
1903        assert_eq!(experimental.description, "Experimental tier");
1904        assert_eq!(experimental.use_cases, vec!["bleeding edge".to_string()]);
1905    }
1906
1907    #[test]
1908    #[cfg(unix)]
1909    fn user_path_pointing_at_a_directory_logs_and_falls_through() {
1910        // A directory exists at the path, so `path.exists()` is true, but
1911        // `read_to_string` errors. The loader logs and falls through.
1912        let dir = tempfile::tempdir().unwrap();
1913        let bogus = dir.path().join("models.yaml");
1914        std::fs::create_dir(&bogus).unwrap();
1915        let registry = ModelRegistry::load_layered_from_paths(None, Some(&bogus), None).unwrap();
1916        let spec = registry.get_model_spec("claude-opus-4-6").unwrap();
1917        assert_eq!(spec.source, ModelSource::Embedded);
1918    }
1919
1920    #[test]
1921    #[cfg(unix)]
1922    fn override_path_pointing_at_a_directory_warns_and_falls_through() {
1923        let dir = tempfile::tempdir().unwrap();
1924        let bogus = dir.path().join("override.yaml");
1925        std::fs::create_dir(&bogus).unwrap();
1926        let registry = ModelRegistry::load_layered_from_paths(None, None, Some(&bogus)).unwrap();
1927        let spec = registry.get_model_spec("claude-opus-4-6").unwrap();
1928        assert_eq!(spec.source, ModelSource::Embedded);
1929    }
1930
1931    #[test]
1932    fn project_layer_recovers_after_user_replaces_top_level_with_scalar() {
1933        // Layer-2 (user) wholesale-replaces the merged accumulator with a
1934        // scalar (early-return branch in `merge_layer_into`). Layer-3
1935        // (project) must hit the "dest is not a mapping" recovery branch
1936        // and rebuild a mapping before merging its own content. Project
1937        // must redeclare `providers` since the user layer wiped them.
1938        let dir = tempfile::tempdir().unwrap();
1939        let user = write_yaml(dir.path(), "user.yaml", "\"junk\"\n");
1940        let project = write_yaml(
1941            dir.path(),
1942            "project.yaml",
1943            r#"
1944version: "1"
1945models:
1946  - provider: "claude"
1947    model: "Project Rescue"
1948    api_identifier: "claude-rescue"
1949    max_output_tokens: 1
1950    input_context: 1
1951    generation: 1.0
1952    tier: "flagship"
1953providers:
1954  custom-provider:
1955    name: "Custom"
1956    api_base: "https://example.invalid"
1957    default_model: "custom-default"
1958    tiers: {}
1959    defaults:
1960      max_output_tokens: 100
1961      input_context: 1000
1962"#,
1963        );
1964        let registry =
1965            ModelRegistry::load_layered_from_paths(Some(&project), Some(&user), None).unwrap();
1966        // Project's model survives the user layer's top-level scalar wipe.
1967        let spec = registry.get_model_spec("claude-rescue").unwrap();
1968        assert_eq!(spec.source, ModelSource::Project);
1969    }
1970
1971    #[test]
1972    fn project_layer_recovers_after_user_replaces_models_with_scalar() {
1973        // Layer-2 sets `models: 42`, replacing the embedded sequence with
1974        // a scalar. Layer-3 must trigger the "dest is not a sequence"
1975        // recovery branch in `merge_models_into` and rebuild the sequence.
1976        let dir = tempfile::tempdir().unwrap();
1977        let user = write_yaml(
1978            dir.path(),
1979            "user.yaml",
1980            r#"
1981version: "1"
1982models: 42
1983"#,
1984        );
1985        let project = write_yaml(
1986            dir.path(),
1987            "project.yaml",
1988            r#"
1989version: "1"
1990models:
1991  - provider: "claude"
1992    model: "Project Rescue"
1993    api_identifier: "claude-rescue"
1994    max_output_tokens: 1
1995    input_context: 1
1996    generation: 1.0
1997    tier: "flagship"
1998"#,
1999        );
2000        let registry =
2001            ModelRegistry::load_layered_from_paths(Some(&project), Some(&user), None).unwrap();
2002        let spec = registry.get_model_spec("claude-rescue").unwrap();
2003        assert_eq!(spec.source, ModelSource::Project);
2004    }
2005
2006    #[test]
2007    fn project_layer_recovers_after_user_replaces_providers_with_scalar() {
2008        // Layer-2 sets `providers: 42`. Layer-3 must trigger the "dest is
2009        // not a mapping" recovery branch in `merge_providers_into`.
2010        let dir = tempfile::tempdir().unwrap();
2011        let user = write_yaml(
2012            dir.path(),
2013            "user.yaml",
2014            r#"
2015version: "1"
2016providers: 42
2017"#,
2018        );
2019        let project = write_yaml(
2020            dir.path(),
2021            "project.yaml",
2022            r#"
2023version: "1"
2024providers:
2025  custom-provider:
2026    name: "Custom"
2027    api_base: "https://example.invalid"
2028    default_model: "custom-default"
2029    tiers: {}
2030    defaults:
2031      max_output_tokens: 100
2032      input_context: 1000
2033"#,
2034        );
2035        let registry =
2036            ModelRegistry::load_layered_from_paths(Some(&project), Some(&user), None).unwrap();
2037        let provider = registry.get_provider_config("custom-provider").unwrap();
2038        assert_eq!(provider.name, "Custom");
2039        assert_eq!(provider.source, ModelSource::Project);
2040    }
2041
2042    #[test]
2043    fn empty_omni_dev_models_yaml_env_var_is_ignored() {
2044        // Exercises the `.filter(|s| !s.is_empty())` branch from `load()`
2045        // directly. The `load()` entry point is not safely callable from
2046        // a unit test because it consults a process-wide OnceLock.
2047        let resolved: Option<PathBuf> = Some(String::new())
2048            .filter(|s| !s.is_empty())
2049            .map(PathBuf::from);
2050        assert!(resolved.is_none());
2051        let resolved: Option<PathBuf> = Some("/some/path".to_string())
2052            .filter(|s| !s.is_empty())
2053            .map(PathBuf::from);
2054        assert_eq!(resolved.as_deref(), Some(Path::new("/some/path")));
2055    }
2056
2057    #[test]
2058    fn model_family_classifies_registered_claude_identifiers() {
2059        let registry = embedded_only();
2060        assert_eq!(
2061            registry.get_model_family("claude-fable-5"),
2062            ModelFamily::Fable
2063        );
2064        assert_eq!(
2065            registry.get_model_family("claude-opus-4-8"),
2066            ModelFamily::Opus
2067        );
2068        assert_eq!(
2069            registry.get_model_family("claude-sonnet-5"),
2070            ModelFamily::Sonnet
2071        );
2072        assert_eq!(
2073            registry.get_model_family("claude-haiku-4-5-20251001"),
2074            ModelFamily::Haiku
2075        );
2076    }
2077
2078    #[test]
2079    fn model_family_normalizes_bedrock_identifiers() {
2080        let registry = embedded_only();
2081        assert_eq!(
2082            registry.get_model_family("us.anthropic.claude-3-7-sonnet-20250219-v1:0"),
2083            ModelFamily::Sonnet
2084        );
2085    }
2086
2087    #[test]
2088    fn model_family_falls_back_to_the_raw_identifier_when_unregistered() {
2089        let registry = embedded_only();
2090        // "claude-opus-5" is not yet in the embedded catalog, but its id
2091        // still names its family β€” the registry-miss fallback must catch it
2092        // rather than reporting Unknown.
2093        assert!(registry.get_model_spec("claude-opus-5").is_none());
2094        assert_eq!(
2095            registry.get_model_family("claude-opus-5"),
2096            ModelFamily::Opus
2097        );
2098    }
2099
2100    #[test]
2101    fn model_family_is_unknown_for_unrecognized_identifiers() {
2102        let registry = embedded_only();
2103        assert_eq!(registry.get_model_family("gpt-5.2"), ModelFamily::Unknown);
2104        assert_eq!(
2105            registry.get_model_family("some-future-model"),
2106            ModelFamily::Unknown
2107        );
2108    }
2109
2110    #[test]
2111    fn model_family_glyph_and_label_cover_every_variant() {
2112        assert_eq!(ModelFamily::Fable.glyph(), "🟠");
2113        assert_eq!(ModelFamily::Opus.glyph(), "🟑");
2114        assert_eq!(ModelFamily::Sonnet.glyph(), "🟒");
2115        assert_eq!(ModelFamily::Haiku.glyph(), "πŸ”΅");
2116        assert_eq!(ModelFamily::Unknown.glyph(), "βšͺ");
2117        assert_eq!(ModelFamily::Fable.label(), "Fable");
2118        assert_eq!(ModelFamily::Opus.label(), "Opus");
2119        assert_eq!(ModelFamily::Sonnet.label(), "Sonnet");
2120        assert_eq!(ModelFamily::Haiku.label(), "Haiku");
2121        assert_eq!(ModelFamily::Unknown.label(), "Claude");
2122    }
2123}