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