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