Skip to main content

pi/
models.rs

1//! Model registry: built-in + models.json overrides.
2
3use crate::auth::{AuthStorage, SapResolvedCredentials, resolve_sap_credentials};
4use crate::error::Error;
5use crate::provider::{Api, InputType, Model, ModelCost};
6use crate::provider_metadata::{
7    ProviderRoutingDefaults, canonical_provider_id, provider_routing_defaults,
8};
9use regex::Regex;
10use serde::{Deserialize, Serialize};
11use std::collections::{HashMap, HashSet};
12use std::fs;
13use std::io::Write;
14use std::path::{Path, PathBuf};
15use std::sync::OnceLock;
16
17#[derive(Debug, Clone)]
18pub struct ModelEntry {
19    pub model: Model,
20    pub api_key: Option<String>,
21    pub headers: HashMap<String, String>,
22    pub auth_header: bool,
23    pub compat: Option<CompatConfig>,
24    /// OAuth config for extension-registered providers that require browser-based auth.
25    pub oauth_config: Option<OAuthConfig>,
26}
27
28impl ModelEntry {
29    /// Whether this model supports xhigh thinking level.
30    pub fn supports_xhigh(&self) -> bool {
31        matches!(
32            self.model.id.as_str(),
33            "gpt-5.1-codex-max"
34                | "gpt-5.2"
35                | "gpt-5.5"
36                | "gpt-5.4"
37                | "gpt-5.2-codex"
38                | "gpt-5.3-codex"
39                | "gpt-5.3-codex-spark"
40        )
41    }
42
43    /// Return the thinking levels that should be exposed for this model.
44    pub fn available_thinking_levels(&self) -> Vec<crate::model::ThinkingLevel> {
45        use crate::model::ThinkingLevel;
46
47        if !self.model.reasoning {
48            return vec![ThinkingLevel::Off];
49        }
50
51        let mut levels = vec![
52            ThinkingLevel::Off,
53            ThinkingLevel::Minimal,
54            ThinkingLevel::Low,
55            ThinkingLevel::Medium,
56            ThinkingLevel::High,
57        ];
58        if self.supports_xhigh() {
59            levels.push(ThinkingLevel::XHigh);
60        }
61        levels
62    }
63
64    /// Clamp a requested thinking level to the model's capabilities.
65    ///
66    /// Non-reasoning models always return `Off`. Models without xhigh support
67    /// downgrade `XHigh` to `High`. All other levels pass through unchanged.
68    pub fn clamp_thinking_level(
69        &self,
70        thinking: crate::model::ThinkingLevel,
71    ) -> crate::model::ThinkingLevel {
72        if !self.model.reasoning {
73            return crate::model::ThinkingLevel::Off;
74        }
75        if thinking == crate::model::ThinkingLevel::XHigh && !self.supports_xhigh() {
76            return crate::model::ThinkingLevel::High;
77        }
78        thinking
79    }
80}
81
82/// OAuth configuration for extension-registered providers.
83#[derive(Debug, Clone)]
84pub struct OAuthConfig {
85    pub auth_url: String,
86    pub token_url: String,
87    pub client_id: String,
88    pub scopes: Vec<String>,
89    pub redirect_uri: Option<String>,
90}
91
92#[derive(Debug, Clone, Default, Deserialize)]
93#[serde(rename_all = "camelCase")]
94pub struct ModelsConfig {
95    pub providers: HashMap<String, ProviderConfig>,
96}
97
98#[derive(Debug, Clone, Default, Deserialize)]
99#[serde(rename_all = "camelCase")]
100pub struct ProviderConfig {
101    pub base_url: Option<String>,
102    pub api: Option<String>,
103    pub api_key: Option<String>,
104    pub headers: Option<HashMap<String, String>>,
105    pub auth_header: Option<bool>,
106    pub compat: Option<CompatConfig>,
107    pub models: Option<Vec<ModelConfig>>,
108}
109
110#[derive(Debug, Clone, Default, Deserialize)]
111#[serde(rename_all = "camelCase")]
112pub struct ModelConfig {
113    pub id: String,
114    pub name: Option<String>,
115    pub api: Option<String>,
116    pub reasoning: Option<bool>,
117    pub input: Option<Vec<String>>,
118    pub cost: Option<ModelCost>,
119    pub context_window: Option<u32>,
120    pub max_tokens: Option<u32>,
121    pub headers: Option<HashMap<String, String>>,
122    pub compat: Option<CompatConfig>,
123}
124
125#[derive(Debug, Clone, Default, Deserialize, Serialize)]
126#[serde(rename_all = "camelCase")]
127pub struct CompatConfig {
128    // ── Capability flags ────────────────────────────────────────────────
129    pub supports_store: Option<bool>,
130    pub supports_developer_role: Option<bool>,
131    pub supports_reasoning_effort: Option<bool>,
132    pub supports_usage_in_streaming: Option<bool>,
133    pub supports_tools: Option<bool>,
134    pub supports_streaming: Option<bool>,
135    pub supports_parallel_tool_calls: Option<bool>,
136
137    // ── Request field overrides ─────────────────────────────────────────
138    /// Override the JSON field name for `max_tokens` (e.g., `"max_completion_tokens"` for o1).
139    pub max_tokens_field: Option<String>,
140    /// Override the system message role name (e.g., `"developer"` for some providers).
141    pub system_role_name: Option<String>,
142    /// Override the stop-reason field name in responses.
143    pub stop_reason_field: Option<String>,
144
145    // ── Per-provider request headers ────────────────────────────────────
146    /// Extra HTTP headers injected into every request for this provider.
147    /// Applied after default headers but before per-request `StreamOptions.headers`.
148    pub custom_headers: Option<HashMap<String, String>>,
149
150    // ── Gateway/routing metadata ────────────────────────────────────────
151    pub open_router_routing: Option<serde_json::Value>,
152    pub vercel_gateway_routing: Option<serde_json::Value>,
153}
154
155#[derive(Debug, Clone)]
156pub struct ModelRegistry {
157    models: Vec<ModelEntry>,
158    error: Option<String>,
159}
160
161#[derive(Debug, Clone)]
162pub struct ModelAutocompleteCandidate {
163    pub slug: String,
164    pub description: Option<String>,
165}
166
167#[derive(Debug, Clone, Deserialize, Serialize)]
168#[serde(rename_all = "camelCase")]
169struct LegacyGeneratedModel {
170    id: String,
171    name: String,
172    api: String,
173    provider: String,
174    #[serde(default)]
175    base_url: String,
176    #[serde(default)]
177    reasoning: bool,
178    #[serde(default)]
179    input: Vec<String>,
180    #[serde(default)]
181    cost: Option<ModelCost>,
182    #[serde(default)]
183    context_window: Option<u32>,
184    #[serde(default)]
185    max_tokens: Option<u32>,
186    #[serde(default)]
187    headers: HashMap<String, String>,
188    #[serde(default)]
189    compat: Option<CompatConfig>,
190}
191
192const LEGACY_MODELS_GENERATED_TS: &str =
193    include_str!("../legacy_pi_mono_code/pi-mono/packages/ai/src/models.generated.ts");
194const UPSTREAM_PROVIDER_MODEL_IDS_JSON: &str =
195    include_str!("../docs/provider-upstream-model-ids-snapshot.json");
196const CODEX_RESPONSES_API_URL: &str = "https://chatgpt.com/backend-api/codex/responses";
197const GOOGLE_GEMINI_CLI_API_URL: &str = "https://cloudcode-pa.googleapis.com";
198const GOOGLE_ANTIGRAVITY_API_URL: &str = "https://daily-cloudcode-pa.sandbox.googleapis.com";
199
200static LEGACY_GENERATED_MODELS_CACHE: OnceLock<Vec<LegacyGeneratedModel>> = OnceLock::new();
201static UPSTREAM_PROVIDER_MODEL_IDS_CACHE: OnceLock<HashMap<String, Vec<String>>> = OnceLock::new();
202static MODEL_AUTOCOMPLETE_CACHE: OnceLock<Vec<ModelAutocompleteCandidate>> = OnceLock::new();
203static MODEL_CATALOG_CACHE_FINGERPRINT: OnceLock<u64> = OnceLock::new();
204static SATISFIES_RE: OnceLock<Regex> = OnceLock::new();
205const INPUT_TEXT_ONLY: [InputType; 1] = [InputType::Text];
206const INPUT_TEXT_AND_IMAGE: [InputType; 2] = [InputType::Text, InputType::Image];
207
208fn canonicalize_openrouter_model_id(model_id: &str) -> String {
209    let trimmed = model_id.trim();
210    match trimmed.to_ascii_lowercase().as_str() {
211        "auto" => "openrouter/auto".to_string(),
212        "gpt-4o-mini" => "openai/gpt-4o-mini".to_string(),
213        "gpt-4o" => "openai/gpt-4o".to_string(),
214        "claude-3.5-sonnet" => "anthropic/claude-3.5-sonnet".to_string(),
215        "gemini-2.5-pro" => "google/gemini-2.5-pro".to_string(),
216        _ => trimmed.to_string(),
217    }
218}
219
220fn canonicalize_model_id_for_provider(provider: &str, model_id: &str) -> String {
221    if canonical_provider_id(provider).is_some_and(|canonical| canonical == "openrouter") {
222        return canonicalize_openrouter_model_id(model_id);
223    }
224    model_id.trim().to_string()
225}
226
227fn normalized_registry_key(provider: &str, model_id: &str) -> (String, String) {
228    let provider = provider.trim();
229    let canonical_provider = canonical_provider_id(provider).unwrap_or(provider);
230    let canonical_model_id = canonicalize_model_id_for_provider(canonical_provider, model_id);
231    (
232        canonical_provider.to_ascii_lowercase(),
233        canonical_model_id.to_ascii_lowercase(),
234    )
235}
236
237fn openrouter_model_lookup_ids(model_id: &str) -> Vec<String> {
238    let raw = model_id.trim().to_string();
239    let canonical = canonicalize_openrouter_model_id(model_id);
240    if canonical.eq_ignore_ascii_case(&raw) {
241        vec![canonical]
242    } else {
243        vec![raw, canonical]
244    }
245}
246
247fn api_fallback_base_url(api: &str) -> Option<&'static str> {
248    match api {
249        "openai-codex-responses" => Some(CODEX_RESPONSES_API_URL),
250        "google-gemini-cli" => Some(GOOGLE_GEMINI_CLI_API_URL),
251        "google-antigravity" => Some(GOOGLE_ANTIGRAVITY_API_URL),
252        _ => None,
253    }
254}
255
256fn parse_input_types(input: &[String]) -> Vec<InputType> {
257    input
258        .iter()
259        .filter_map(|value| match value.as_str() {
260            "text" => Some(InputType::Text),
261            "image" => Some(InputType::Image),
262            _ => None,
263        })
264        .collect()
265}
266
267fn legacy_generated_models_cache_path() -> Option<PathBuf> {
268    let checksum = crc32c::crc32c(LEGACY_MODELS_GENERATED_TS.as_bytes());
269    dirs::cache_dir().map(|dir| {
270        dir.join("pi")
271            .join("models-cache")
272            .join(format!("legacy-generated-models-{checksum:08x}.json"))
273    })
274}
275
276fn load_legacy_generated_models_cache() -> Option<Vec<LegacyGeneratedModel>> {
277    let path = legacy_generated_models_cache_path()?;
278    let cache = fs::read_to_string(path).ok()?;
279    serde_json::from_str::<Vec<LegacyGeneratedModel>>(&cache).ok()
280}
281
282fn persist_legacy_generated_models_cache(models: &[LegacyGeneratedModel]) {
283    let Some(path) = legacy_generated_models_cache_path() else {
284        return;
285    };
286    if path.exists() {
287        return;
288    }
289    let Some(parent) = path.parent() else {
290        return;
291    };
292    if fs::create_dir_all(parent).is_err() {
293        return;
294    }
295
296    let temp_path = path.with_extension(format!("tmp-{}", std::process::id()));
297    let Ok(file) = fs::OpenOptions::new()
298        .write(true)
299        .create_new(true)
300        .open(&temp_path)
301    else {
302        return;
303    };
304    let mut writer = std::io::BufWriter::new(file);
305    if serde_json::to_writer(&mut writer, models).is_ok() && writer.flush().is_ok() {
306        let _ = fs::rename(&temp_path, path);
307    } else {
308        let _ = fs::remove_file(&temp_path);
309    }
310}
311
312fn parse_legacy_generated_models() -> Vec<LegacyGeneratedModel> {
313    if let Some(cached) = load_legacy_generated_models_cache() {
314        return cached;
315    }
316
317    let Some(models_decl_start) = LEGACY_MODELS_GENERATED_TS.find("export const MODELS =") else {
318        tracing::warn!("Legacy model catalog missing MODELS declaration");
319        return Vec::new();
320    };
321    let Some(object_start_rel) = LEGACY_MODELS_GENERATED_TS[models_decl_start..].find('{') else {
322        tracing::warn!("Legacy model catalog missing object start after MODELS declaration");
323        return Vec::new();
324    };
325    let object_start = models_decl_start + object_start_rel;
326    let Some(end_marker_rel) = LEGACY_MODELS_GENERATED_TS[object_start..].rfind("} as const;")
327    else {
328        tracing::warn!("Legacy model catalog missing end marker");
329        return Vec::new();
330    };
331    let end_marker = object_start + end_marker_rel;
332
333    let mut object_source = LEGACY_MODELS_GENERATED_TS[object_start..=end_marker]
334        .trim_end_matches(" as const;")
335        .to_string();
336    let satisfies_re = SATISFIES_RE.get_or_init(|| {
337        Regex::new(r#"\s+satisfies\s+Model<"[^"]+">"#).expect("valid satisfies regex")
338    });
339    object_source = satisfies_re.replace_all(&object_source, "").into_owned();
340
341    let parsed: HashMap<String, HashMap<String, LegacyGeneratedModel>> =
342        match json5::from_str(&object_source) {
343            Ok(value) => value,
344            Err(err) => {
345                tracing::warn!(error = %err, "Failed to parse legacy model catalog");
346                return Vec::new();
347            }
348        };
349
350    let mut models = parsed
351        .into_values()
352        .flat_map(HashMap::into_values)
353        .collect::<Vec<_>>();
354    models.sort_by(|a, b| {
355        a.provider
356            .cmp(&b.provider)
357            .then_with(|| a.id.cmp(&b.id))
358            .then_with(|| a.api.cmp(&b.api))
359    });
360    persist_legacy_generated_models_cache(&models);
361    models
362}
363
364fn legacy_generated_models() -> &'static [LegacyGeneratedModel] {
365    LEGACY_GENERATED_MODELS_CACHE
366        .get_or_init(parse_legacy_generated_models)
367        .as_slice()
368}
369
370fn parse_upstream_provider_model_ids() -> HashMap<String, Vec<String>> {
371    let parsed: HashMap<String, Vec<String>> =
372        match serde_json::from_str(UPSTREAM_PROVIDER_MODEL_IDS_JSON) {
373            Ok(value) => value,
374            Err(err) => {
375                tracing::warn!(error = %err, "Failed to parse upstream provider model snapshot");
376                return HashMap::new();
377            }
378        };
379
380    let mut by_provider: HashMap<String, Vec<String>> = HashMap::new();
381    merge_provider_model_ids(&mut by_provider, parsed);
382    merge_provider_model_ids(&mut by_provider, parse_user_model_overrides());
383
384    for ids in by_provider.values_mut() {
385        ids.sort_unstable();
386        ids.dedup();
387    }
388    by_provider
389}
390
391fn merge_provider_model_ids(
392    target: &mut HashMap<String, Vec<String>>,
393    source: HashMap<String, Vec<String>>,
394) {
395    for (provider, ids) in source {
396        let provider = provider.trim();
397        if provider.is_empty() {
398            continue;
399        }
400        let canonical_provider = canonical_provider_id(provider)
401            .unwrap_or(provider)
402            .to_string();
403        let entry = target.entry(canonical_provider.clone()).or_default();
404        for model_id in ids {
405            let normalized = canonicalize_model_id_for_provider(&canonical_provider, &model_id);
406            if !normalized.is_empty() {
407                entry.push(normalized);
408            }
409        }
410    }
411}
412
413/// Path to the user's optional model-override file.
414///
415/// Resolution order:
416/// 1. `PI_MODELS_OVERRIDE` env var (absolute path) — primarily for tests and
417///    advanced users who want to keep the override outside the standard config
418///    directory.
419/// 2. `<config_dir>/pi/models-override.json` — `<config_dir>` is whatever
420///    `dirs::config_dir()` reports (e.g. `~/.config` on Linux,
421///    `~/Library/Application Support` on macOS).
422///
423/// Returns `None` when no config directory can be resolved and no env override
424/// is set; callers treat that as "no override available".
425fn user_model_overrides_path() -> Option<PathBuf> {
426    if let Ok(env_path) = std::env::var("PI_MODELS_OVERRIDE") {
427        let trimmed = env_path.trim();
428        if !trimmed.is_empty() {
429            return Some(PathBuf::from(trimmed));
430        }
431    }
432    dirs::config_dir().map(|dir| dir.join("pi").join("models-override.json"))
433}
434
435/// Parse the user-supplied override file. Same shape as the bundled snapshot:
436/// `{ "<provider>": ["<model-id>", ...], ... }`. Missing or unreadable files
437/// are silently ignored; malformed JSON logs a warning and is treated as
438/// empty so a typo in the override never breaks pi startup.
439fn parse_user_model_overrides() -> HashMap<String, Vec<String>> {
440    user_model_overrides_path()
441        .map(|path| parse_user_model_overrides_at(&path))
442        .unwrap_or_default()
443}
444
445fn parse_user_model_overrides_at(path: &Path) -> HashMap<String, Vec<String>> {
446    let content = match fs::read_to_string(path) {
447        Ok(content) => content,
448        Err(err) => {
449            if err.kind() != std::io::ErrorKind::NotFound {
450                tracing::debug!(
451                    path = %path.display(),
452                    error = %err,
453                    "User model override file present but unreadable; ignoring"
454                );
455            }
456            return HashMap::new();
457        }
458    };
459    if content.trim().is_empty() {
460        return HashMap::new();
461    }
462    match serde_json::from_str::<HashMap<String, Vec<String>>>(&content) {
463        Ok(value) => {
464            tracing::debug!(
465                path = %path.display(),
466                providers = value.len(),
467                "Loaded user model override file"
468            );
469            value
470        }
471        Err(err) => {
472            tracing::warn!(
473                path = %path.display(),
474                error = %err,
475                "Failed to parse pi user model override file; ignoring"
476            );
477            HashMap::new()
478        }
479    }
480}
481
482/// CRC32C of the user override file at process start, or 0 when no override
483/// exists. Folded into [`model_catalog_cache_fingerprint`] so consumers that
484/// memoize against the fingerprint refresh when a user changes their override.
485fn user_model_overrides_fingerprint() -> u32 {
486    user_model_overrides_path().map_or(0, |path| user_model_overrides_fingerprint_at(&path))
487}
488
489fn user_model_overrides_fingerprint_at(path: &Path) -> u32 {
490    fs::read(path)
491        .ok()
492        .map_or(0, |bytes| crc32c::crc32c(&bytes))
493}
494
495fn upstream_provider_model_ids() -> &'static HashMap<String, Vec<String>> {
496    UPSTREAM_PROVIDER_MODEL_IDS_CACHE.get_or_init(parse_upstream_provider_model_ids)
497}
498
499pub fn model_autocomplete_candidates() -> &'static [ModelAutocompleteCandidate] {
500    MODEL_AUTOCOMPLETE_CACHE
501        .get_or_init(|| {
502            let mut candidates = legacy_generated_models()
503                .iter()
504                .map(|entry| ModelAutocompleteCandidate {
505                    slug: format!("{}/{}", entry.provider, entry.id),
506                    description: Some(entry.name.clone()).filter(|name| !name.trim().is_empty()),
507                })
508                .collect::<Vec<_>>();
509            for (provider, ids) in upstream_provider_model_ids() {
510                let provider = provider.trim();
511                if provider.is_empty() {
512                    continue;
513                }
514                for id in ids {
515                    if id.trim().is_empty() {
516                        continue;
517                    }
518                    candidates.push(ModelAutocompleteCandidate {
519                        slug: format!("{provider}/{id}"),
520                        description: None,
521                    });
522                }
523            }
524            candidates.push(ModelAutocompleteCandidate {
525                slug: "anthropic/claude-sonnet-4-6".to_string(),
526                description: Some("Claude Sonnet 4.6".to_string()),
527            });
528            candidates.push(ModelAutocompleteCandidate {
529                slug: "openai/gpt-5.5".to_string(),
530                description: Some("GPT-5.5".to_string()),
531            });
532            candidates.push(ModelAutocompleteCandidate {
533                slug: "openai/gpt-5.4".to_string(),
534                description: Some("GPT-5.4".to_string()),
535            });
536            candidates.push(ModelAutocompleteCandidate {
537                slug: "openai-codex/gpt-5.5".to_string(),
538                description: Some("GPT-5.5 Codex".to_string()),
539            });
540            candidates.push(ModelAutocompleteCandidate {
541                slug: "openai-codex/gpt-5.4".to_string(),
542                description: Some("GPT-5.4 Codex".to_string()),
543            });
544            candidates.push(ModelAutocompleteCandidate {
545                slug: "openai-codex/gpt-5.2-codex".to_string(),
546                description: Some("GPT-5.2 Codex".to_string()),
547            });
548            candidates.push(ModelAutocompleteCandidate {
549                slug: "google-gemini-cli/gemini-2.5-pro".to_string(),
550                description: Some("Gemini 2.5 Pro (CLI)".to_string()),
551            });
552            candidates.push(ModelAutocompleteCandidate {
553                slug: "google-antigravity/gemini-3-flash".to_string(),
554                description: Some("Gemini 3 Flash (Antigravity)".to_string()),
555            });
556            candidates.sort_by_key(|candidate| candidate.slug.to_ascii_lowercase());
557            candidates.dedup_by(|a, b| a.slug.eq_ignore_ascii_case(&b.slug));
558            candidates
559        })
560        .as_slice()
561}
562
563pub fn model_catalog_cache_fingerprint() -> u64 {
564    *MODEL_CATALOG_CACHE_FINGERPRINT.get_or_init(|| {
565        let legacy = u64::from(crc32c::crc32c(LEGACY_MODELS_GENERATED_TS.as_bytes()));
566        let upstream = u64::from(crc32c::crc32c(UPSTREAM_PROVIDER_MODEL_IDS_JSON.as_bytes()));
567        let user_override = u64::from(user_model_overrides_fingerprint());
568        // Mix the override CRC into both halves so any change forces cache
569        // invalidation regardless of whether the snapshot or the override
570        // moved.
571        (legacy ^ user_override) << 32 | (upstream ^ user_override)
572    })
573}
574
575pub(crate) fn normalize_api_key_opt(api_key: Option<String>) -> Option<String> {
576    api_key.and_then(|key| {
577        let trimmed = key.trim();
578        (!trimmed.is_empty()).then(|| trimmed.to_string())
579    })
580}
581
582pub(crate) fn model_requires_configured_credential(entry: &ModelEntry) -> bool {
583    let provider = entry.model.provider.as_str();
584    entry.auth_header
585        || crate::provider_metadata::provider_metadata(provider)
586            .is_some_and(|meta| !meta.auth_env_keys.is_empty())
587        || entry.oauth_config.is_some()
588}
589
590pub(crate) fn model_entry_is_ready(entry: &ModelEntry) -> bool {
591    !model_requires_configured_credential(entry)
592        || entry
593            .api_key
594            .as_ref()
595            .is_some_and(|value| !value.trim().is_empty())
596}
597
598#[derive(Clone, Copy, Debug, PartialEq, Eq)]
599enum ModelRegistryLoadMode {
600    Full,
601    ListingLite,
602}
603
604impl ModelRegistry {
605    #[cfg(test)]
606    pub(crate) fn from_entries_for_tests(entries: Vec<ModelEntry>) -> Self {
607        Self {
608            models: entries,
609            error: None,
610        }
611    }
612
613    pub fn load(auth: &AuthStorage, models_path: Option<PathBuf>) -> Self {
614        Self::load_with_mode(auth, models_path, ModelRegistryLoadMode::Full)
615    }
616
617    pub fn load_for_listing(auth: &AuthStorage, models_path: Option<PathBuf>) -> Self {
618        Self::load_with_mode(auth, models_path, ModelRegistryLoadMode::ListingLite)
619    }
620
621    fn load_with_mode(
622        auth: &AuthStorage,
623        models_path: Option<PathBuf>,
624        mode: ModelRegistryLoadMode,
625    ) -> Self {
626        let mut models = built_in_models(auth, mode);
627        let mut error = None;
628
629        if let Some(path) = models_path {
630            if path.exists() {
631                match std::fs::read_to_string(&path)
632                    .map_err(|e| Error::config(format!("Failed to read models.json: {e}")))
633                    .and_then(|s| serde_json::from_str::<ModelsConfig>(&s).map_err(Error::from))
634                {
635                    Ok(config) => {
636                        apply_custom_models(auth, &mut models, &config, path.parent());
637                    }
638                    Err(e) => {
639                        error = Some(format!("{e}\n\nFile: {}", path.display()));
640                    }
641                }
642            }
643        }
644
645        Self { models, error }
646    }
647
648    pub fn models(&self) -> &[ModelEntry] {
649        &self.models
650    }
651
652    pub fn error(&self) -> Option<&str> {
653        self.error.as_deref()
654    }
655
656    pub fn available_models(&self) -> Vec<&ModelEntry> {
657        self.models
658            .iter()
659            .filter(|m| model_entry_is_ready(m))
660            .collect()
661    }
662
663    pub fn get_available(&self) -> Vec<ModelEntry> {
664        self.available_models().into_iter().cloned().collect()
665    }
666
667    pub fn find(&self, provider: &str, id: &str) -> Option<ModelEntry> {
668        let provider = provider.trim();
669        let canonical_provider = canonical_provider_id(provider).unwrap_or(provider);
670        let is_openrouter = canonical_provider.eq_ignore_ascii_case("openrouter");
671        // Avoid Vec + String allocation for the common (non-OpenRouter) path.
672        let openrouter_ids = if is_openrouter {
673            openrouter_model_lookup_ids(id)
674        } else {
675            Vec::new()
676        };
677        let trimmed_id = id.trim();
678
679        self.models
680            .iter()
681            .find(|m| {
682                let model_provider = m.model.provider.as_str();
683                let model_provider_canonical =
684                    canonical_provider_id(model_provider).unwrap_or(model_provider);
685                let provider_matches = model_provider.eq_ignore_ascii_case(provider)
686                    || model_provider.eq_ignore_ascii_case(canonical_provider)
687                    || model_provider_canonical.eq_ignore_ascii_case(provider)
688                    || model_provider_canonical.eq_ignore_ascii_case(canonical_provider);
689                provider_matches
690                    && if is_openrouter {
691                        openrouter_ids
692                            .iter()
693                            .any(|lookup_id| m.model.id.eq_ignore_ascii_case(lookup_id))
694                    } else {
695                        m.model.id.eq_ignore_ascii_case(trimmed_id)
696                    }
697            })
698            .cloned()
699    }
700
701    /// Find a model by ID alone (ignoring provider), useful for extension models
702    /// where the provider name may be custom.
703    ///
704    /// When multiple providers carry the same model ID, the canonical/primary
705    /// provider is preferred (e.g. `anthropic` for Claude models, `openai` for
706    /// GPT models). If no canonical match exists, the first alphabetical
707    /// provider wins, ensuring deterministic results regardless of insertion
708    /// order.
709    pub fn find_by_id(&self, id: &str) -> Option<ModelEntry> {
710        let id = id.trim();
711        let mut best: Option<&ModelEntry> = None;
712        for entry in &self.models {
713            if !entry.model.id.eq_ignore_ascii_case(id) {
714                continue;
715            }
716            let Some(current_best) = best else {
717                best = Some(entry);
718                continue;
719            };
720            let entry_canonical = is_canonical_provider_for_model(id, &entry.model.provider);
721            let best_canonical = is_canonical_provider_for_model(id, &current_best.model.provider);
722            if entry_canonical && !best_canonical {
723                best = Some(entry);
724            } else if entry_canonical == best_canonical
725                && entry.model.provider < current_best.model.provider
726            {
727                // Tie-break alphabetically for determinism.
728                best = Some(entry);
729            }
730        }
731        best.cloned()
732    }
733
734    /// Merge extension-provided model entries into the registry.
735    pub fn merge_entries(&mut self, entries: Vec<ModelEntry>) {
736        for entry in entries {
737            // Skip duplicates (canonical provider + canonical model id, case-insensitive).
738            let entry_key = normalized_registry_key(&entry.model.provider, &entry.model.id);
739            let exists = self
740                .models
741                .iter()
742                .any(|m| normalized_registry_key(&m.model.provider, &m.model.id) == entry_key);
743            if !exists {
744                self.models.push(entry);
745            }
746        }
747    }
748}
749
750/// Returns `true` when `provider` is the canonical/primary source for a model
751/// identified by `model_id`. Used by `find_by_id` to prefer the authoritative
752/// provider when the same model ID appears under multiple resellers.
753fn is_canonical_provider_for_model(model_id: &str, provider: &str) -> bool {
754    let id_lower = model_id.to_ascii_lowercase();
755    let prov_lower = provider.to_ascii_lowercase();
756    if id_lower.starts_with("claude") {
757        prov_lower == "anthropic"
758    } else if id_lower.starts_with("gpt-")
759        || id_lower.starts_with("o1")
760        || id_lower.starts_with("o3")
761        || id_lower.starts_with("o4")
762    {
763        prov_lower == "openai"
764    } else if id_lower.starts_with("gemini") {
765        prov_lower == "google"
766    } else if id_lower.starts_with("command") {
767        prov_lower == "cohere"
768    } else if id_lower.starts_with("mistral") || id_lower.starts_with("codestral") {
769        prov_lower == "mistral"
770    } else if id_lower.starts_with("deepseek") {
771        prov_lower == "deepseek"
772    } else {
773        false
774    }
775}
776
777/// Determine per-model reasoning capability. Returns `Some(true/false)` for
778/// known model ID patterns, `None` for unknown models (caller should fall back
779/// to the provider-level default).
780///
781/// This prevents non-reasoning models like `gpt-4o` from inheriting a
782/// provider-level `reasoning: true` flag from their provider (Issue #19).
783fn model_is_reasoning(model_id: &str) -> Option<bool> {
784    let raw_id = model_id.to_ascii_lowercase();
785    let id = [
786        "claude-",
787        "gpt-",
788        "gemini-",
789        "command-",
790        "deepseek",
791        "qwq-",
792        "mistral",
793        "codestral",
794        "pixtral",
795        "llama",
796        "o1",
797        "o3",
798        "o4",
799    ]
800    .iter()
801    .find_map(|needle| raw_id.find(needle).map(|idx| &raw_id[idx..]))
802    .unwrap_or(raw_id.as_str());
803
804    // OpenAI: o1/o3/o4 series and gpt-5.x are reasoning.
805    // All gpt-4 variants (gpt-4o, gpt-4-turbo, gpt-4-0613, etc.) and gpt-3.5 are NOT.
806    if id.starts_with("o1") || id.starts_with("o3") || id.starts_with("o4") {
807        return Some(true);
808    }
809    if id.starts_with("gpt-5") {
810        return Some(true);
811    }
812    if id.starts_with("gpt-4") || id.starts_with("gpt-3.5") {
813        return Some(false);
814    }
815
816    // Anthropic: Claude 3.5 Sonnet and Claude 4+ support extended thinking.
817    // Claude 3 (Haiku/Sonnet/Opus) and Claude 3.5 Haiku do NOT.
818    if id.starts_with("claude-3-5-haiku")
819        || id.starts_with("claude-3-haiku")
820        || id.starts_with("claude-3-sonnet")
821        || id.starts_with("claude-3-opus")
822    {
823        return Some(false);
824    }
825    if id.starts_with("claude") {
826        // Claude 3.5 Sonnet, Claude 4.x, Claude Opus 4+, Claude Sonnet 4+ etc.
827        return Some(true);
828    }
829
830    // Google: gemini-2.5+ and gemini-2.0-flash-thinking are reasoning.
831    // All other gemini models (2.0-flash, 2.0-flash-lite, 1.x, etc.) are NOT.
832    if id.starts_with("gemini-2.5")
833        || id.starts_with("gemini-3")
834        || id.starts_with("gemini-2.0-flash-thinking")
835    {
836        return Some(true);
837    }
838    if id.starts_with("gemini") {
839        return Some(false);
840    }
841
842    // Cohere: command-a is reasoning; command-r is not.
843    if id.starts_with("command-a") {
844        return Some(true);
845    }
846    if id.starts_with("command-r") {
847        return Some(false);
848    }
849
850    // DeepSeek: deepseek-reasoner (R1) is reasoning; deepseek-chat (V3) and others are not.
851    if id.starts_with("deepseek-reasoner") || id.starts_with("deepseek-r") {
852        return Some(true);
853    }
854    if id.starts_with("deepseek") {
855        return Some(false);
856    }
857
858    // Qwen: qwq- series are reasoning.
859    if id.starts_with("qwq-") {
860        return Some(true);
861    }
862
863    // Mistral/Codestral: no reasoning support currently.
864    if id.starts_with("mistral") || id.starts_with("codestral") || id.starts_with("pixtral") {
865        return Some(false);
866    }
867
868    // Meta Llama: no reasoning support.
869    if id.starts_with("llama") {
870        return Some(false);
871    }
872
873    // Groq-hosted models: groq model IDs typically include the upstream model name
874    // (e.g., "llama-3.3-70b-versatile"), so the upstream checks above should catch them.
875    None
876}
877
878/// Resolve the effective reasoning flag for a model, preferring per-model
879/// detection over the provider-level default.
880fn effective_reasoning(model_id: &str, provider_default: bool) -> bool {
881    model_is_reasoning(model_id).unwrap_or(provider_default)
882}
883
884fn native_adapter_seed_defaults(provider: &str) -> Option<AdHocProviderDefaults> {
885    match provider {
886        "openai-codex" => Some(AdHocProviderDefaults {
887            api: "openai-codex-responses",
888            base_url: CODEX_RESPONSES_API_URL,
889            auth_header: true,
890            reasoning: true,
891            input: &INPUT_TEXT_AND_IMAGE,
892            context_window: 272_000,
893            max_tokens: 128_000,
894        }),
895        "google-gemini-cli" => Some(AdHocProviderDefaults {
896            api: "google-gemini-cli",
897            base_url: GOOGLE_GEMINI_CLI_API_URL,
898            auth_header: true,
899            reasoning: true,
900            input: &INPUT_TEXT_AND_IMAGE,
901            context_window: 128_000,
902            max_tokens: 8192,
903        }),
904        "google-antigravity" => Some(AdHocProviderDefaults {
905            api: "google-gemini-cli",
906            base_url: GOOGLE_ANTIGRAVITY_API_URL,
907            auth_header: true,
908            reasoning: true,
909            input: &INPUT_TEXT_AND_IMAGE,
910            context_window: 128_000,
911            max_tokens: 8192,
912        }),
913        "azure-openai" => Some(AdHocProviderDefaults {
914            api: "openai-completions",
915            base_url: "",
916            auth_header: false,
917            reasoning: true,
918            input: &INPUT_TEXT_AND_IMAGE,
919            context_window: 128_000,
920            max_tokens: 16_384,
921        }),
922        "github-copilot" | "sap-ai-core" => Some(AdHocProviderDefaults {
923            api: "openai-completions",
924            base_url: "",
925            auth_header: true,
926            reasoning: true,
927            input: &INPUT_TEXT_ONLY,
928            context_window: 128_000,
929            max_tokens: 16_384,
930        }),
931        "gitlab" => Some(AdHocProviderDefaults {
932            api: "gitlab-chat",
933            base_url: "",
934            auth_header: true,
935            reasoning: true,
936            input: &INPUT_TEXT_ONLY,
937            context_window: 128_000,
938            max_tokens: 16_384,
939        }),
940        _ => None,
941    }
942}
943
944fn custom_provider_defaults(provider: &str) -> Option<AdHocProviderDefaults> {
945    let canonical_provider = canonical_provider_id(provider).unwrap_or(provider);
946    ad_hoc_provider_defaults(canonical_provider)
947        .or_else(|| native_adapter_seed_defaults(canonical_provider))
948}
949
950fn legacy_provider_ids() -> HashSet<String> {
951    legacy_generated_models()
952        .iter()
953        .map(|model| {
954            let provider = model.provider.trim();
955            canonical_provider_id(provider)
956                .unwrap_or(provider)
957                .to_ascii_lowercase()
958        })
959        .collect()
960}
961
962fn resolve_provider_api_key_cached(
963    auth: &AuthStorage,
964    canonical_provider: &str,
965    provider: &str,
966    canonical_cache: &mut HashMap<String, Option<String>>,
967    provider_cache: &mut HashMap<String, Option<String>>,
968) -> Option<String> {
969    let canonical_key = canonical_provider.to_ascii_lowercase();
970    let canonical_result = canonical_cache
971        .entry(canonical_key)
972        .or_insert_with(|| auth.resolve_api_key(canonical_provider, None))
973        .clone();
974
975    if canonical_result.is_some() || canonical_provider.eq_ignore_ascii_case(provider) {
976        return canonical_result;
977    }
978
979    provider_cache
980        .entry(provider.to_ascii_lowercase())
981        .or_insert_with(|| auth.resolve_api_key(provider, None))
982        .clone()
983}
984
985fn append_upstream_nonlegacy_models(
986    auth: &AuthStorage,
987    models: &mut Vec<ModelEntry>,
988    seen: &mut HashSet<String>,
989    canonical_api_key_cache: &mut HashMap<String, Option<String>>,
990    provider_api_key_cache: &mut HashMap<String, Option<String>>,
991) {
992    let legacy_providers = legacy_provider_ids();
993    for (provider, ids) in upstream_provider_model_ids() {
994        let provider = provider.trim();
995        if provider.is_empty() {
996            continue;
997        }
998        let canonical_provider = canonical_provider_id(provider).unwrap_or(provider);
999        if legacy_providers.contains(&canonical_provider.to_ascii_lowercase()) {
1000            continue;
1001        }
1002
1003        let Some(defaults) = ad_hoc_provider_defaults(canonical_provider)
1004            .or_else(|| native_adapter_seed_defaults(canonical_provider))
1005        else {
1006            continue;
1007        };
1008
1009        let api_key = resolve_provider_api_key_cached(
1010            auth,
1011            canonical_provider,
1012            provider,
1013            canonical_api_key_cache,
1014            provider_api_key_cache,
1015        );
1016
1017        for model_id in ids {
1018            let normalized_model_id =
1019                canonicalize_model_id_for_provider(canonical_provider, model_id);
1020            if normalized_model_id.is_empty() {
1021                continue;
1022            }
1023            let dedupe_key = format!(
1024                "{}::{}",
1025                canonical_provider.to_ascii_lowercase(),
1026                normalized_model_id.to_ascii_lowercase()
1027            );
1028            if !seen.insert(dedupe_key) {
1029                continue;
1030            }
1031
1032            let reasoning = effective_reasoning(&normalized_model_id, defaults.reasoning);
1033            models.push(ModelEntry {
1034                model: Model {
1035                    id: normalized_model_id.clone(),
1036                    name: normalized_model_id.clone(),
1037                    api: defaults.api.to_string(),
1038                    provider: canonical_provider.to_string(),
1039                    base_url: defaults.base_url.to_string(),
1040                    reasoning,
1041                    input: defaults.input.to_vec(),
1042                    cost: ModelCost {
1043                        input: 0.0,
1044                        output: 0.0,
1045                        cache_read: 0.0,
1046                        cache_write: 0.0,
1047                    },
1048                    context_window: defaults.context_window,
1049                    max_tokens: defaults.max_tokens,
1050                    headers: HashMap::new(),
1051                },
1052                api_key: api_key.clone(),
1053                headers: HashMap::new(),
1054                auth_header: defaults.auth_header,
1055                compat: None,
1056                oauth_config: None,
1057            });
1058        }
1059    }
1060}
1061
1062#[allow(clippy::too_many_lines)]
1063fn built_in_models(auth: &AuthStorage, mode: ModelRegistryLoadMode) -> Vec<ModelEntry> {
1064    let mut models = Vec::with_capacity(legacy_generated_models().len() + 8);
1065    let mut seen = HashSet::new();
1066    let mut canonical_api_key_cache: HashMap<String, Option<String>> = HashMap::new();
1067    let mut provider_api_key_cache: HashMap<String, Option<String>> = HashMap::new();
1068
1069    for legacy in legacy_generated_models() {
1070        let provider = legacy.provider.trim();
1071        if provider.is_empty() {
1072            continue;
1073        }
1074
1075        let normalized_model_id = canonicalize_model_id_for_provider(provider, &legacy.id);
1076        if normalized_model_id.is_empty() {
1077            continue;
1078        }
1079
1080        let dedupe_key = format!(
1081            "{}::{}",
1082            provider.to_ascii_lowercase(),
1083            normalized_model_id.to_ascii_lowercase()
1084        );
1085        if !seen.insert(dedupe_key) {
1086            continue;
1087        }
1088
1089        let routing_defaults = provider_routing_defaults(provider);
1090        let api_string = if mode == ModelRegistryLoadMode::Full {
1091            legacy
1092                .api
1093                .parse::<Api>()
1094                .unwrap_or_else(|_| Api::Custom(legacy.api.clone()))
1095                .to_string()
1096        } else {
1097            legacy.api.clone()
1098        };
1099
1100        let base_url = if mode == ModelRegistryLoadMode::Full {
1101            if !legacy.base_url.trim().is_empty() {
1102                legacy.base_url.trim().to_string()
1103            } else if let Some(default_base) = routing_defaults
1104                .map(|defaults| defaults.base_url)
1105                .or_else(|| api_fallback_base_url(api_string.as_str()))
1106            {
1107                default_base.to_string()
1108            } else {
1109                String::new()
1110            }
1111        } else {
1112            String::new()
1113        };
1114
1115        let input = {
1116            let parsed = parse_input_types(&legacy.input);
1117            if parsed.is_empty() {
1118                routing_defaults
1119                    .map_or_else(|| vec![InputType::Text], |defaults| defaults.input.to_vec())
1120            } else {
1121                parsed
1122            }
1123        };
1124
1125        let auth_header = match api_string.as_str() {
1126            "openai-codex-responses" | "google-gemini-cli" => true,
1127            _ => routing_defaults.is_some_and(|defaults| defaults.auth_header),
1128        };
1129
1130        let canonical_provider = canonical_provider_id(provider).unwrap_or(provider);
1131        let api_key = resolve_provider_api_key_cached(
1132            auth,
1133            canonical_provider,
1134            provider,
1135            &mut canonical_api_key_cache,
1136            &mut provider_api_key_cache,
1137        );
1138
1139        let default_cost = ModelCost {
1140            input: 0.0,
1141            output: 0.0,
1142            cache_read: 0.0,
1143            cache_write: 0.0,
1144        };
1145        let model_name = if mode == ModelRegistryLoadMode::Full && !legacy.name.trim().is_empty() {
1146            legacy.name.clone()
1147        } else {
1148            normalized_model_id.clone()
1149        };
1150        let model_headers = if mode == ModelRegistryLoadMode::Full {
1151            legacy.headers.clone()
1152        } else {
1153            HashMap::new()
1154        };
1155        let entry_headers = if mode == ModelRegistryLoadMode::Full {
1156            legacy.headers.clone()
1157        } else {
1158            HashMap::new()
1159        };
1160
1161        models.push(ModelEntry {
1162            model: Model {
1163                id: normalized_model_id.clone(),
1164                name: model_name,
1165                api: api_string,
1166                provider: provider.to_string(),
1167                base_url,
1168                reasoning: effective_reasoning(&normalized_model_id, legacy.reasoning),
1169                input,
1170                cost: if mode == ModelRegistryLoadMode::Full {
1171                    legacy.cost.clone().unwrap_or_else(|| default_cost.clone())
1172                } else {
1173                    default_cost
1174                },
1175                context_window: legacy.context_window.unwrap_or_else(|| {
1176                    routing_defaults.map_or(128_000, |defaults| defaults.context_window)
1177                }),
1178                max_tokens: legacy.max_tokens.unwrap_or_else(|| {
1179                    routing_defaults.map_or(16_384, |defaults| defaults.max_tokens)
1180                }),
1181                headers: model_headers,
1182            },
1183            api_key,
1184            headers: entry_headers,
1185            auth_header,
1186            compat: if mode == ModelRegistryLoadMode::Full {
1187                legacy.compat.clone()
1188            } else {
1189                None
1190            },
1191            oauth_config: None,
1192        });
1193    }
1194
1195    append_upstream_nonlegacy_models(
1196        auth,
1197        &mut models,
1198        &mut seen,
1199        &mut canonical_api_key_cache,
1200        &mut provider_api_key_cache,
1201    );
1202
1203    // Ensure the latest Sonnet alias is present in built-ins.
1204    if !models.iter().any(|entry| {
1205        entry.model.provider == "anthropic"
1206            && (entry.model.id == "claude-sonnet-4-6"
1207                || entry.model.id == "claude-sonnet-4-6-20260217")
1208    }) {
1209        models.push(ModelEntry {
1210            model: Model {
1211                id: "claude-sonnet-4-6".to_string(),
1212                name: "Claude Sonnet 4.6".to_string(),
1213                api: if mode == ModelRegistryLoadMode::Full {
1214                    Api::AnthropicMessages.to_string()
1215                } else {
1216                    "anthropic-messages".to_string()
1217                },
1218                provider: "anthropic".to_string(),
1219                base_url: if mode == ModelRegistryLoadMode::Full {
1220                    "https://api.anthropic.com/v1/messages".to_string()
1221                } else {
1222                    String::new()
1223                },
1224                reasoning: true,
1225                input: vec![InputType::Text, InputType::Image],
1226                cost: ModelCost {
1227                    input: 0.0,
1228                    output: 0.0,
1229                    cache_read: 0.0,
1230                    cache_write: 0.0,
1231                },
1232                context_window: 1_000_000,
1233                max_tokens: 128_000,
1234                headers: HashMap::new(),
1235            },
1236            api_key: resolve_provider_api_key_cached(
1237                auth,
1238                "anthropic",
1239                "anthropic",
1240                &mut canonical_api_key_cache,
1241                &mut provider_api_key_cache,
1242            ),
1243            headers: HashMap::new(),
1244            auth_header: false,
1245            compat: None,
1246            oauth_config: None,
1247        });
1248    }
1249
1250    // Ensure the latest GPT-5 default exists for OpenAI routing.
1251    //
1252    // The legacy catalog can lag behind upstream model IDs; we add a
1253    // conservative seed so listing, lookup, and autocomplete stay current.
1254    if !models
1255        .iter()
1256        .any(|entry| entry.model.provider == "openai" && entry.model.id == "gpt-5.5")
1257    {
1258        models.push(ModelEntry {
1259            model: Model {
1260                id: "gpt-5.5".to_string(),
1261                name: "GPT-5.5".to_string(),
1262                api: if mode == ModelRegistryLoadMode::Full {
1263                    Api::OpenAIResponses.to_string()
1264                } else {
1265                    "openai-responses".to_string()
1266                },
1267                provider: "openai".to_string(),
1268                base_url: if mode == ModelRegistryLoadMode::Full {
1269                    "https://api.openai.com/v1".to_string()
1270                } else {
1271                    String::new()
1272                },
1273                reasoning: true,
1274                input: vec![InputType::Text, InputType::Image],
1275                cost: ModelCost {
1276                    input: 0.0,
1277                    output: 0.0,
1278                    cache_read: 0.0,
1279                    cache_write: 0.0,
1280                },
1281                context_window: 1_000_000,
1282                max_tokens: 128_000,
1283                headers: HashMap::new(),
1284            },
1285            api_key: resolve_provider_api_key_cached(
1286                auth,
1287                "openai",
1288                "openai",
1289                &mut canonical_api_key_cache,
1290                &mut provider_api_key_cache,
1291            ),
1292            headers: HashMap::new(),
1293            auth_header: true,
1294            compat: None,
1295            oauth_config: None,
1296        });
1297    }
1298
1299    if !models
1300        .iter()
1301        .any(|entry| entry.model.provider == "openai" && entry.model.id == "gpt-5.4")
1302    {
1303        models.push(ModelEntry {
1304            model: Model {
1305                id: "gpt-5.4".to_string(),
1306                name: "GPT-5.4".to_string(),
1307                api: if mode == ModelRegistryLoadMode::Full {
1308                    Api::OpenAIResponses.to_string()
1309                } else {
1310                    "openai-responses".to_string()
1311                },
1312                provider: "openai".to_string(),
1313                base_url: if mode == ModelRegistryLoadMode::Full {
1314                    "https://api.openai.com/v1".to_string()
1315                } else {
1316                    String::new()
1317                },
1318                reasoning: true,
1319                input: vec![InputType::Text, InputType::Image],
1320                cost: ModelCost {
1321                    input: 0.0,
1322                    output: 0.0,
1323                    cache_read: 0.0,
1324                    cache_write: 0.0,
1325                },
1326                context_window: 400_000,
1327                max_tokens: 128_000,
1328                headers: HashMap::new(),
1329            },
1330            api_key: resolve_provider_api_key_cached(
1331                auth,
1332                "openai",
1333                "openai",
1334                &mut canonical_api_key_cache,
1335                &mut provider_api_key_cache,
1336            ),
1337            headers: HashMap::new(),
1338            auth_header: true,
1339            compat: None,
1340            oauth_config: None,
1341        });
1342    }
1343
1344    // Ensure the latest Codex default exists for OpenAI Codex (ChatGPT) routing.
1345    //
1346    // The legacy catalog can lag behind upstream model IDs; we use a conservative
1347    // seed here to keep the default selection stable.
1348    if !models
1349        .iter()
1350        .any(|entry| entry.model.provider == "openai-codex" && entry.model.id == "gpt-5.5")
1351    {
1352        models.push(ModelEntry {
1353            model: Model {
1354                id: "gpt-5.5".to_string(),
1355                name: "GPT-5.5 Codex".to_string(),
1356                api: if mode == ModelRegistryLoadMode::Full {
1357                    Api::OpenAICodexResponses.to_string()
1358                } else {
1359                    "openai-codex-responses".to_string()
1360                },
1361                provider: "openai-codex".to_string(),
1362                base_url: if mode == ModelRegistryLoadMode::Full {
1363                    "https://chatgpt.com/backend-api".to_string()
1364                } else {
1365                    String::new()
1366                },
1367                reasoning: true,
1368                input: vec![InputType::Text, InputType::Image],
1369                cost: ModelCost {
1370                    input: 0.0,
1371                    output: 0.0,
1372                    cache_read: 0.0,
1373                    cache_write: 0.0,
1374                },
1375                context_window: 1_000_000,
1376                max_tokens: 128_000,
1377                headers: HashMap::new(),
1378            },
1379            api_key: resolve_provider_api_key_cached(
1380                auth,
1381                "openai-codex",
1382                "openai-codex",
1383                &mut canonical_api_key_cache,
1384                &mut provider_api_key_cache,
1385            ),
1386            headers: HashMap::new(),
1387            auth_header: true,
1388            compat: None,
1389            oauth_config: None,
1390        });
1391    }
1392
1393    if !models
1394        .iter()
1395        .any(|entry| entry.model.provider == "openai-codex" && entry.model.id == "gpt-5.4")
1396    {
1397        models.push(ModelEntry {
1398            model: Model {
1399                id: "gpt-5.4".to_string(),
1400                name: "GPT-5.4 Codex".to_string(),
1401                api: if mode == ModelRegistryLoadMode::Full {
1402                    Api::OpenAICodexResponses.to_string()
1403                } else {
1404                    "openai-codex-responses".to_string()
1405                },
1406                provider: "openai-codex".to_string(),
1407                base_url: if mode == ModelRegistryLoadMode::Full {
1408                    "https://chatgpt.com/backend-api".to_string()
1409                } else {
1410                    String::new()
1411                },
1412                reasoning: true,
1413                input: vec![InputType::Text, InputType::Image],
1414                cost: ModelCost {
1415                    input: 0.0,
1416                    output: 0.0,
1417                    cache_read: 0.0,
1418                    cache_write: 0.0,
1419                },
1420                context_window: 272_000,
1421                max_tokens: 128_000,
1422                headers: HashMap::new(),
1423            },
1424            api_key: resolve_provider_api_key_cached(
1425                auth,
1426                "openai-codex",
1427                "openai-codex",
1428                &mut canonical_api_key_cache,
1429                &mut provider_api_key_cache,
1430            ),
1431            headers: HashMap::new(),
1432            auth_header: true,
1433            compat: None,
1434            oauth_config: None,
1435        });
1436    }
1437
1438    if !models
1439        .iter()
1440        .any(|entry| entry.model.provider == "openai-codex" && entry.model.id == "gpt-5.2-codex")
1441    {
1442        models.push(ModelEntry {
1443            model: Model {
1444                id: "gpt-5.2-codex".to_string(),
1445                name: "GPT-5.2 Codex".to_string(),
1446                api: if mode == ModelRegistryLoadMode::Full {
1447                    Api::OpenAICodexResponses.to_string()
1448                } else {
1449                    "openai-codex-responses".to_string()
1450                },
1451                provider: "openai-codex".to_string(),
1452                base_url: if mode == ModelRegistryLoadMode::Full {
1453                    "https://chatgpt.com/backend-api".to_string()
1454                } else {
1455                    String::new()
1456                },
1457                reasoning: true,
1458                input: vec![InputType::Text, InputType::Image],
1459                cost: ModelCost {
1460                    input: 0.0,
1461                    output: 0.0,
1462                    cache_read: 0.0,
1463                    cache_write: 0.0,
1464                },
1465                context_window: 272_000,
1466                max_tokens: 128_000,
1467                headers: HashMap::new(),
1468            },
1469            api_key: resolve_provider_api_key_cached(
1470                auth,
1471                "openai-codex",
1472                "openai-codex",
1473                &mut canonical_api_key_cache,
1474                &mut provider_api_key_cache,
1475            ),
1476            headers: HashMap::new(),
1477            auth_header: true,
1478            compat: None,
1479            oauth_config: None,
1480        });
1481    }
1482
1483    // Keep the prior Codex default available until the bundled legacy catalog catches up.
1484    if !models
1485        .iter()
1486        .any(|entry| entry.model.provider == "openai-codex" && entry.model.id == "gpt-5.3-codex")
1487    {
1488        models.push(ModelEntry {
1489            model: Model {
1490                id: "gpt-5.3-codex".to_string(),
1491                name: "GPT-5.3 Codex".to_string(),
1492                api: if mode == ModelRegistryLoadMode::Full {
1493                    Api::OpenAICodexResponses.to_string()
1494                } else {
1495                    "openai-codex-responses".to_string()
1496                },
1497                provider: "openai-codex".to_string(),
1498                base_url: if mode == ModelRegistryLoadMode::Full {
1499                    "https://chatgpt.com/backend-api".to_string()
1500                } else {
1501                    String::new()
1502                },
1503                reasoning: true,
1504                input: vec![InputType::Text, InputType::Image],
1505                cost: ModelCost {
1506                    input: 0.0,
1507                    output: 0.0,
1508                    cache_read: 0.0,
1509                    cache_write: 0.0,
1510                },
1511                context_window: 272_000,
1512                max_tokens: 128_000,
1513                headers: HashMap::new(),
1514            },
1515            api_key: resolve_provider_api_key_cached(
1516                auth,
1517                "openai-codex",
1518                "openai-codex",
1519                &mut canonical_api_key_cache,
1520                &mut provider_api_key_cache,
1521            ),
1522            headers: HashMap::new(),
1523            auth_header: true,
1524            compat: None,
1525            oauth_config: None,
1526        });
1527    }
1528
1529    // Ensure the latest Codex Spark variant exists for OpenAI Codex routing.
1530    if !models.iter().any(|entry| {
1531        entry.model.provider == "openai-codex" && entry.model.id == "gpt-5.3-codex-spark"
1532    }) {
1533        models.push(ModelEntry {
1534            model: Model {
1535                id: "gpt-5.3-codex-spark".to_string(),
1536                name: "GPT-5.3 Codex Spark".to_string(),
1537                api: if mode == ModelRegistryLoadMode::Full {
1538                    Api::OpenAICodexResponses.to_string()
1539                } else {
1540                    "openai-codex-responses".to_string()
1541                },
1542                provider: "openai-codex".to_string(),
1543                base_url: if mode == ModelRegistryLoadMode::Full {
1544                    "https://chatgpt.com/backend-api".to_string()
1545                } else {
1546                    String::new()
1547                },
1548                reasoning: true,
1549                input: vec![InputType::Text, InputType::Image],
1550                cost: ModelCost {
1551                    input: 0.0,
1552                    output: 0.0,
1553                    cache_read: 0.0,
1554                    cache_write: 0.0,
1555                },
1556                context_window: 272_000,
1557                max_tokens: 128_000,
1558                headers: HashMap::new(),
1559            },
1560            api_key: resolve_provider_api_key_cached(
1561                auth,
1562                "openai-codex",
1563                "openai-codex",
1564                &mut canonical_api_key_cache,
1565                &mut provider_api_key_cache,
1566            ),
1567            headers: HashMap::new(),
1568            auth_header: true,
1569            compat: None,
1570            oauth_config: None,
1571        });
1572    }
1573
1574    if !models.iter().any(|entry| {
1575        entry.model.provider == "google-gemini-cli" && entry.model.id == "gemini-2.5-pro"
1576    }) {
1577        models.push(ModelEntry {
1578            model: Model {
1579                id: "gemini-2.5-pro".to_string(),
1580                name: "Gemini 2.5 Pro".to_string(),
1581                api: "google-gemini-cli".to_string(),
1582                provider: "google-gemini-cli".to_string(),
1583                base_url: if mode == ModelRegistryLoadMode::Full {
1584                    GOOGLE_GEMINI_CLI_API_URL.to_string()
1585                } else {
1586                    String::new()
1587                },
1588                reasoning: true,
1589                input: vec![InputType::Text, InputType::Image],
1590                cost: ModelCost {
1591                    input: 0.0,
1592                    output: 0.0,
1593                    cache_read: 0.0,
1594                    cache_write: 0.0,
1595                },
1596                context_window: 128_000,
1597                max_tokens: 8192,
1598                headers: HashMap::new(),
1599            },
1600            api_key: resolve_provider_api_key_cached(
1601                auth,
1602                "google",
1603                "google-gemini-cli",
1604                &mut canonical_api_key_cache,
1605                &mut provider_api_key_cache,
1606            ),
1607            headers: HashMap::new(),
1608            auth_header: true,
1609            compat: None,
1610            oauth_config: None,
1611        });
1612    }
1613
1614    if !models.iter().any(|entry| {
1615        entry.model.provider == "google-antigravity" && entry.model.id == "gemini-3-flash"
1616    }) {
1617        models.push(ModelEntry {
1618            model: Model {
1619                id: "gemini-3-flash".to_string(),
1620                name: "Gemini 3 Flash".to_string(),
1621                api: "google-gemini-cli".to_string(),
1622                provider: "google-antigravity".to_string(),
1623                base_url: if mode == ModelRegistryLoadMode::Full {
1624                    GOOGLE_ANTIGRAVITY_API_URL.to_string()
1625                } else {
1626                    String::new()
1627                },
1628                reasoning: true,
1629                input: vec![InputType::Text, InputType::Image],
1630                cost: ModelCost {
1631                    input: 0.0,
1632                    output: 0.0,
1633                    cache_read: 0.0,
1634                    cache_write: 0.0,
1635                },
1636                context_window: 128_000,
1637                max_tokens: 8192,
1638                headers: HashMap::new(),
1639            },
1640            api_key: resolve_provider_api_key_cached(
1641                auth,
1642                "google",
1643                "google-antigravity",
1644                &mut canonical_api_key_cache,
1645                &mut provider_api_key_cache,
1646            ),
1647            headers: HashMap::new(),
1648            auth_header: true,
1649            compat: None,
1650            oauth_config: None,
1651        });
1652    }
1653
1654    // Sort for deterministic find_by_id: canonical providers first, then alphabetical.
1655    models.sort_by(|a, b| {
1656        let priority = |e: &ModelEntry| -> u8 {
1657            let p = e.model.provider.as_str();
1658            let id = e.model.id.as_str();
1659            // Canonical provider gets priority 0
1660            let is_canonical = (id.starts_with("claude") && p == "anthropic")
1661                || (id.starts_with("gpt-") && p == "openai")
1662                || (id.starts_with("o1") && p == "openai")
1663                || (id.starts_with("o3") && p == "openai")
1664                || (id.starts_with("o4") && p == "openai")
1665                || (id.starts_with("gemini") && p == "google")
1666                || (id.starts_with("command") && p == "cohere");
1667            u8::from(!is_canonical)
1668        };
1669        priority(a)
1670            .cmp(&priority(b))
1671            .then_with(|| a.model.provider.cmp(&b.model.provider))
1672            .then_with(|| a.model.id.cmp(&b.model.id))
1673    });
1674
1675    models
1676}
1677
1678#[allow(clippy::too_many_lines)]
1679fn apply_custom_models(
1680    auth: &AuthStorage,
1681    models: &mut Vec<ModelEntry>,
1682    config: &ModelsConfig,
1683    base_dir: Option<&Path>,
1684) {
1685    for (provider_id, provider_cfg) in &config.providers {
1686        let provider_id_str = provider_id.as_str();
1687        let provider_defaults = custom_provider_defaults(provider_id);
1688        let default_api = provider_defaults.map_or("openai-completions", |defaults| defaults.api);
1689        let provider_api = provider_cfg.api.as_deref().unwrap_or(default_api);
1690        let provider_api_parsed: Api = provider_api
1691            .parse()
1692            .unwrap_or_else(|_| Api::Custom(provider_api.to_string()));
1693        let provider_api_string = provider_api_parsed.to_string();
1694        let provider_base = provider_cfg.base_url.clone().unwrap_or_else(|| {
1695            provider_defaults.map_or_else(
1696                || {
1697                    api_fallback_base_url(provider_api_string.as_str())
1698                        .unwrap_or("https://api.openai.com/v1")
1699                        .to_string()
1700                },
1701                |defaults| {
1702                    if defaults.base_url.is_empty() {
1703                        api_fallback_base_url(provider_api_string.as_str())
1704                            .unwrap_or_default()
1705                            .to_string()
1706                    } else {
1707                        defaults.base_url.to_string()
1708                    }
1709                },
1710            )
1711        });
1712
1713        let provider_headers = resolve_headers_with_base(provider_cfg.headers.as_ref(), base_dir);
1714        let canonical_provider = canonical_provider_id(provider_id).unwrap_or(provider_id_str);
1715        let provider_matches = |candidate_provider: &str| {
1716            let candidate_canonical =
1717                canonical_provider_id(candidate_provider).unwrap_or(candidate_provider);
1718            candidate_provider.eq_ignore_ascii_case(provider_id_str)
1719                || candidate_provider.eq_ignore_ascii_case(canonical_provider)
1720                || candidate_canonical.eq_ignore_ascii_case(provider_id_str)
1721                || candidate_canonical.eq_ignore_ascii_case(canonical_provider)
1722        };
1723        let provider_key = provider_cfg
1724            .api_key
1725            .as_deref()
1726            .and_then(|value| resolve_value_with_base(value, base_dir))
1727            .or_else(|| auth.resolve_api_key(canonical_provider, None));
1728
1729        let auth_header = provider_cfg
1730            .auth_header
1731            .unwrap_or_else(|| provider_defaults.is_some_and(|defaults| defaults.auth_header));
1732
1733        if provider_defaults.is_some() {
1734            tracing::debug!(
1735                event = "pi.provider.schema_defaults",
1736                provider = %provider_id,
1737                canonical_provider = %canonical_provider,
1738                api = %provider_api_string,
1739                base_url = %provider_base,
1740                auth_header,
1741                "Applied provider metadata defaults"
1742            );
1743        }
1744
1745        let has_models = provider_cfg.models.as_ref().is_some();
1746        let is_override = !has_models;
1747
1748        if is_override {
1749            for entry in models
1750                .iter_mut()
1751                .filter(|m| provider_matches(&m.model.provider))
1752            {
1753                // Only override base_url and api if explicitly set in models.json.
1754                // Otherwise keep the built-in defaults (e.g. anthropic's /v1/messages URL).
1755                if provider_cfg.base_url.is_some() {
1756                    entry.model.base_url.clone_from(&provider_base);
1757                }
1758                if provider_cfg.api.is_some() {
1759                    entry.model.api.clone_from(&provider_api_string);
1760                }
1761                if should_apply_headers_override(provider_cfg.headers.as_ref(), &provider_headers) {
1762                    entry.headers.clone_from(&provider_headers);
1763                }
1764                if provider_key.is_some() {
1765                    entry.api_key.clone_from(&provider_key);
1766                }
1767                if provider_cfg.compat.is_some() {
1768                    entry.compat.clone_from(&provider_cfg.compat);
1769                }
1770                if provider_cfg.auth_header.is_some() {
1771                    entry.auth_header = auth_header;
1772                }
1773            }
1774            continue;
1775        }
1776
1777        // Remove built-in provider models if fully overridden
1778        models.retain(|m| !provider_matches(&m.model.provider));
1779
1780        let mut normalized_provider_ids = HashSet::new();
1781        for model_cfg in provider_cfg.models.clone().unwrap_or_default() {
1782            let normalized_model_id =
1783                canonicalize_model_id_for_provider(provider_id, &model_cfg.id);
1784            if normalized_model_id.is_empty() {
1785                tracing::warn!(
1786                    provider = %provider_id,
1787                    model_id = %model_cfg.id,
1788                    "Skipping model with empty normalized id"
1789                );
1790                continue;
1791            }
1792
1793            if canonical_provider == "openrouter"
1794                && !normalized_provider_ids.insert(normalized_model_id.to_ascii_lowercase())
1795            {
1796                tracing::warn!(
1797                    provider = %provider_id,
1798                    model_id = %normalized_model_id,
1799                    "Skipping duplicate OpenRouter model id after alias normalization"
1800                );
1801                continue;
1802            }
1803
1804            let model_api = model_cfg.api.as_deref().unwrap_or(provider_api);
1805            let model_api_parsed: Api = model_api
1806                .parse()
1807                .unwrap_or_else(|_| Api::Custom(model_api.to_string()));
1808            let model_headers = merge_headers(
1809                &provider_headers,
1810                resolve_headers_with_base(model_cfg.headers.as_ref(), base_dir),
1811            );
1812            let default_input_types = provider_defaults
1813                .map_or_else(|| vec![InputType::Text], |defaults| defaults.input.to_vec());
1814            let input_types = model_cfg.input.as_ref().map_or_else(
1815                || default_input_types.clone(),
1816                |input| {
1817                    input
1818                        .iter()
1819                        .filter_map(|i| match i.as_str() {
1820                            "text" => Some(InputType::Text),
1821                            "image" => Some(InputType::Image),
1822                            _ => None,
1823                        })
1824                        .collect::<Vec<_>>()
1825                },
1826            );
1827            let input_types = if input_types.is_empty() {
1828                default_input_types
1829            } else {
1830                input_types
1831            };
1832            let default_reasoning = provider_defaults.is_some_and(|defaults| defaults.reasoning);
1833            let default_context_window =
1834                provider_defaults.map_or(128_000, |defaults| defaults.context_window);
1835            let default_max_tokens =
1836                provider_defaults.map_or(16_384, |defaults| defaults.max_tokens);
1837
1838            let model = Model {
1839                id: normalized_model_id.clone(),
1840                name: model_cfg
1841                    .name
1842                    .clone()
1843                    .unwrap_or_else(|| normalized_model_id.clone()),
1844                api: model_api_parsed.to_string(),
1845                provider: provider_id.clone(),
1846                base_url: provider_base.clone(),
1847                reasoning: model_cfg.reasoning.unwrap_or_else(|| {
1848                    effective_reasoning(&normalized_model_id, default_reasoning)
1849                }),
1850                input: input_types,
1851                cost: model_cfg.cost.clone().unwrap_or(ModelCost {
1852                    input: 0.0,
1853                    output: 0.0,
1854                    cache_read: 0.0,
1855                    cache_write: 0.0,
1856                }),
1857                context_window: model_cfg.context_window.unwrap_or(default_context_window),
1858                max_tokens: model_cfg.max_tokens.unwrap_or(default_max_tokens),
1859                headers: HashMap::new(),
1860            };
1861
1862            models.push(ModelEntry {
1863                model,
1864                api_key: provider_key.clone(),
1865                headers: model_headers,
1866                auth_header,
1867                compat: merge_compat(provider_cfg.compat.as_ref(), model_cfg.compat.as_ref()),
1868                oauth_config: None,
1869            });
1870        }
1871    }
1872}
1873
1874fn merge_compat(
1875    provider_compat: Option<&CompatConfig>,
1876    model_compat: Option<&CompatConfig>,
1877) -> Option<CompatConfig> {
1878    match (provider_compat, model_compat) {
1879        (None, None) => None,
1880        (Some(provider), None) => Some(provider.clone()),
1881        (None, Some(model)) => Some(model.clone()),
1882        (Some(provider), Some(model)) => {
1883            let custom_headers = match (&provider.custom_headers, &model.custom_headers) {
1884                (None, None) => None,
1885                (Some(headers), None) | (None, Some(headers)) => Some(headers.clone()),
1886                (Some(provider_headers), Some(model_headers)) => {
1887                    let mut merged = provider_headers.clone();
1888                    for (key, value) in model_headers {
1889                        merged.insert(key.clone(), value.clone());
1890                    }
1891                    Some(merged)
1892                }
1893            };
1894
1895            Some(CompatConfig {
1896                supports_store: model.supports_store.or(provider.supports_store),
1897                supports_developer_role: model
1898                    .supports_developer_role
1899                    .or(provider.supports_developer_role),
1900                supports_reasoning_effort: model
1901                    .supports_reasoning_effort
1902                    .or(provider.supports_reasoning_effort),
1903                supports_usage_in_streaming: model
1904                    .supports_usage_in_streaming
1905                    .or(provider.supports_usage_in_streaming),
1906                supports_tools: model.supports_tools.or(provider.supports_tools),
1907                supports_streaming: model.supports_streaming.or(provider.supports_streaming),
1908                supports_parallel_tool_calls: model
1909                    .supports_parallel_tool_calls
1910                    .or(provider.supports_parallel_tool_calls),
1911                max_tokens_field: model
1912                    .max_tokens_field
1913                    .clone()
1914                    .or_else(|| provider.max_tokens_field.clone()),
1915                system_role_name: model
1916                    .system_role_name
1917                    .clone()
1918                    .or_else(|| provider.system_role_name.clone()),
1919                stop_reason_field: model
1920                    .stop_reason_field
1921                    .clone()
1922                    .or_else(|| provider.stop_reason_field.clone()),
1923                custom_headers,
1924                open_router_routing: model
1925                    .open_router_routing
1926                    .clone()
1927                    .or_else(|| provider.open_router_routing.clone()),
1928                vercel_gateway_routing: model
1929                    .vercel_gateway_routing
1930                    .clone()
1931                    .or_else(|| provider.vercel_gateway_routing.clone()),
1932            })
1933        }
1934    }
1935}
1936
1937fn merge_headers(
1938    base: &HashMap<String, String>,
1939    override_headers: HashMap<String, String>,
1940) -> HashMap<String, String> {
1941    let mut merged = base.clone();
1942    for (k, v) in override_headers {
1943        merged.insert(k, v);
1944    }
1945    merged
1946}
1947
1948fn should_apply_headers_override(
1949    configured_headers: Option<&HashMap<String, String>>,
1950    resolved_headers: &HashMap<String, String>,
1951) -> bool {
1952    configured_headers.is_some_and(|headers| headers.is_empty() || !resolved_headers.is_empty())
1953}
1954
1955#[cfg(test)]
1956fn resolve_headers(headers: Option<&HashMap<String, String>>) -> HashMap<String, String> {
1957    resolve_headers_with_base(headers, None)
1958}
1959
1960fn resolve_headers_with_base(
1961    headers: Option<&HashMap<String, String>>,
1962    base_dir: Option<&Path>,
1963) -> HashMap<String, String> {
1964    let mut resolved = HashMap::new();
1965    if let Some(headers) = headers {
1966        for (k, v) in headers {
1967            if let Some(val) = resolve_value_with_base(v, base_dir) {
1968                resolved.insert(k.clone(), val);
1969            }
1970        }
1971    }
1972    resolved
1973}
1974
1975#[cfg(test)]
1976fn resolve_value(value: &str) -> Option<String> {
1977    resolve_value_with_base(value, None)
1978}
1979
1980fn resolve_value_with_base(value: &str, base_dir: Option<&Path>) -> Option<String> {
1981    resolve_value_with_resolvers(value, base_dir, |var| std::env::var(var).ok())
1982}
1983
1984/// Testable helper. Behaves the same as [`resolve_value_with_base`] but with an
1985/// injectable environment lookup so unit tests can exercise the env-var
1986/// indirection path without mutating process-wide state (the crate forbids
1987/// `unsafe`, so `std::env::set_var` cannot be used in tests).
1988fn resolve_value_with_resolvers<F>(
1989    value: &str,
1990    base_dir: Option<&Path>,
1991    env_lookup: F,
1992) -> Option<String>
1993where
1994    F: Fn(&str) -> Option<String>,
1995{
1996    if let Some(rest) = value.strip_prefix('!') {
1997        return resolve_shell(rest);
1998    }
1999
2000    if let Some(var_name) = value.strip_prefix("env:") {
2001        if var_name.is_empty() {
2002            return None;
2003        }
2004        return env_lookup(var_name).filter(|v| !v.is_empty());
2005    }
2006
2007    if let Some(file_path) = value.strip_prefix("file:") {
2008        if file_path.is_empty() {
2009            return None;
2010        }
2011        let path = Path::new(file_path);
2012        let resolved_path = if path.is_absolute() {
2013            path.to_path_buf()
2014        } else if let Some(base_dir) = base_dir {
2015            base_dir.join(path)
2016        } else {
2017            path.to_path_buf()
2018        };
2019        return std::fs::read_to_string(resolved_path)
2020            .ok()
2021            .map(|contents| contents.trim().to_string())
2022            .filter(|v| !v.is_empty());
2023    }
2024
2025    // pi parity (issue #64): values that look like an env var name and end with
2026    // `_API_KEY` (e.g. `DASHSCOPE_API_KEY`) are treated as a reference to that
2027    // env var, matching the original `pi` convention. Real provider API keys do
2028    // not end with the literal suffix `_API_KEY`, so this is a safe signal that
2029    // the user wants indirection rather than a literal credential.
2030    if looks_like_api_key_env_var(value) {
2031        match env_lookup(value) {
2032            Some(env_value) => {
2033                let trimmed = env_value.trim();
2034                if trimmed.is_empty() {
2035                    tracing::warn!(
2036                        event = "pi.models.api_key_env_empty",
2037                        var = value,
2038                        "models.json apiKey references env var that is set but empty; \
2039                         falling back to literal value"
2040                    );
2041                } else {
2042                    return Some(trimmed.to_string());
2043                }
2044            }
2045            None => {
2046                tracing::warn!(
2047                    event = "pi.models.api_key_env_missing",
2048                    var = value,
2049                    "models.json apiKey references an env var that is not set; \
2050                     falling back to literal value (auth will likely fail)"
2051                );
2052            }
2053        }
2054    }
2055
2056    if value.is_empty() {
2057        None
2058    } else {
2059        Some(value.to_string())
2060    }
2061}
2062
2063/// Whether `value` should be treated as the *name* of an environment variable
2064/// holding the real API key (matching the original `pi` convention).
2065///
2066/// Conservative check: uppercase ASCII letters/digits/underscores, starting
2067/// with a letter, ending with the literal suffix `_API_KEY`, and at least one
2068/// character before that suffix (so `_API_KEY` itself is rejected).
2069fn looks_like_api_key_env_var(value: &str) -> bool {
2070    const SUFFIX: &str = "_API_KEY";
2071    if !value.ends_with(SUFFIX) {
2072        return false;
2073    }
2074    let prefix = &value[..value.len() - SUFFIX.len()];
2075    if prefix.is_empty() {
2076        return false;
2077    }
2078    let mut chars = prefix.chars();
2079    let Some(first) = chars.next() else {
2080        return false;
2081    };
2082    if !first.is_ascii_uppercase() {
2083        return false;
2084    }
2085    chars.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
2086}
2087
2088fn resolve_shell(cmd: &str) -> Option<String> {
2089    let output = if cfg!(windows) {
2090        std::process::Command::new("cmd")
2091            .args(["/C", cmd])
2092            .stdin(std::process::Stdio::null())
2093            .output()
2094            .ok()?
2095    } else {
2096        std::process::Command::new("sh")
2097            .arg("-c")
2098            .arg(cmd)
2099            .stdin(std::process::Stdio::null())
2100            .output()
2101            .ok()?
2102    };
2103
2104    if !output.status.success() {
2105        return None;
2106    }
2107    let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
2108    if stdout.is_empty() {
2109        None
2110    } else {
2111        Some(stdout)
2112    }
2113}
2114
2115/// Convenience for default models.json path.
2116pub fn default_models_path(agent_dir: &Path) -> PathBuf {
2117    agent_dir.join("models.json")
2118}
2119
2120// === Ad-hoc model support ===
2121
2122#[derive(Debug, Clone, Copy)]
2123struct AdHocProviderDefaults {
2124    api: &'static str,
2125    base_url: &'static str,
2126    auth_header: bool,
2127    reasoning: bool,
2128    input: &'static [InputType],
2129    context_window: u32,
2130    max_tokens: u32,
2131}
2132
2133impl From<ProviderRoutingDefaults> for AdHocProviderDefaults {
2134    fn from(value: ProviderRoutingDefaults) -> Self {
2135        Self {
2136            api: value.api,
2137            base_url: value.base_url,
2138            auth_header: value.auth_header,
2139            reasoning: value.reasoning,
2140            input: value.input,
2141            context_window: value.context_window,
2142            max_tokens: value.max_tokens,
2143        }
2144    }
2145}
2146
2147fn ad_hoc_provider_defaults(provider: &str) -> Option<AdHocProviderDefaults> {
2148    provider_routing_defaults(provider).map(AdHocProviderDefaults::from)
2149}
2150
2151fn sap_chat_completions_endpoint(service_url: &str, model_id: &str) -> Option<String> {
2152    let base = service_url.trim().trim_end_matches('/');
2153    let deployment = model_id.trim();
2154    if base.is_empty() || deployment.is_empty() {
2155        return None;
2156    }
2157    Some(format!(
2158        "{base}/v2/inference/deployments/{deployment}/chat/completions"
2159    ))
2160}
2161
2162fn ad_hoc_model_entry_with_sap_resolver<F>(
2163    provider: &str,
2164    model_id: &str,
2165    mut resolve_sap: F,
2166) -> Option<ModelEntry>
2167where
2168    F: FnMut() -> Option<SapResolvedCredentials>,
2169{
2170    if canonical_provider_id(provider).is_some_and(|canonical| canonical == "sap-ai-core") {
2171        let sap_creds = resolve_sap()?;
2172        let base_url = sap_chat_completions_endpoint(&sap_creds.service_url, model_id)?;
2173        return Some(ModelEntry {
2174            model: Model {
2175                id: model_id.to_string(),
2176                name: model_id.to_string(),
2177                api: "openai-completions".to_string(),
2178                provider: provider.to_string(),
2179                base_url,
2180                reasoning: effective_reasoning(model_id, true),
2181                input: vec![InputType::Text],
2182                cost: ModelCost {
2183                    input: 0.0,
2184                    output: 0.0,
2185                    cache_read: 0.0,
2186                    cache_write: 0.0,
2187                },
2188                context_window: 128_000,
2189                max_tokens: 16_384,
2190                headers: HashMap::new(),
2191            },
2192            api_key: None,
2193            headers: HashMap::new(),
2194            auth_header: true,
2195            compat: None,
2196            oauth_config: None,
2197        });
2198    }
2199
2200    let defaults = ad_hoc_provider_defaults(provider)?;
2201    let normalized_model_id = canonicalize_model_id_for_provider(provider, model_id);
2202    if normalized_model_id.is_empty() {
2203        return None;
2204    }
2205    let reasoning = effective_reasoning(&normalized_model_id, defaults.reasoning);
2206    Some(ModelEntry {
2207        model: Model {
2208            id: normalized_model_id.clone(),
2209            name: normalized_model_id,
2210            api: defaults.api.to_string(),
2211            provider: provider.to_string(),
2212            base_url: defaults.base_url.to_string(),
2213            reasoning,
2214            input: defaults.input.to_vec(),
2215            cost: ModelCost {
2216                input: 0.0,
2217                output: 0.0,
2218                cache_read: 0.0,
2219                cache_write: 0.0,
2220            },
2221            context_window: defaults.context_window,
2222            max_tokens: defaults.max_tokens,
2223            headers: HashMap::new(),
2224        },
2225        api_key: None,
2226        headers: HashMap::new(),
2227        auth_header: defaults.auth_header,
2228        compat: None,
2229        oauth_config: None,
2230    })
2231}
2232
2233pub(crate) fn ad_hoc_model_entry(provider: &str, model_id: &str) -> Option<ModelEntry> {
2234    let auth = AuthStorage::load(crate::config::Config::auth_path()).ok();
2235    let mut entry = ad_hoc_model_entry_with_sap_resolver(provider, model_id, || {
2236        auth.as_ref().and_then(resolve_sap_credentials)
2237    })?;
2238
2239    // Synthesized entries start without credentials. Resolve them from stored
2240    // auth / environment variables so `model_entry_is_ready` reflects reality
2241    // and downstream selection logic does not treat an otherwise-usable
2242    // provider as unconfigured.
2243    if entry.api_key.is_none()
2244        && let Some(auth) = auth.as_ref()
2245    {
2246        entry.api_key = normalize_api_key_opt(auth.resolve_api_key(provider, None));
2247    }
2248
2249    Some(entry)
2250}
2251
2252#[cfg(test)]
2253mod tests {
2254    use super::*;
2255    use crate::auth::{AuthCredential, AuthStorage};
2256    use tempfile::tempdir;
2257
2258    fn test_auth_storage() -> (tempfile::TempDir, AuthStorage) {
2259        let dir = tempdir().expect("tempdir");
2260        let auth_path = dir.path().join("auth.json");
2261        let mut auth = AuthStorage::load(auth_path).expect("load auth");
2262        auth.set(
2263            "anthropic",
2264            AuthCredential::ApiKey {
2265                key: "anthropic-auth-key".to_string(),
2266            },
2267        );
2268        auth.set(
2269            "openai",
2270            AuthCredential::ApiKey {
2271                key: "openai-auth-key".to_string(),
2272            },
2273        );
2274        auth.set(
2275            "google",
2276            AuthCredential::ApiKey {
2277                key: "google-auth-key".to_string(),
2278            },
2279        );
2280        auth.set(
2281            "openrouter",
2282            AuthCredential::ApiKey {
2283                key: "openrouter-auth-key".to_string(),
2284            },
2285        );
2286        auth.set(
2287            "acme",
2288            AuthCredential::ApiKey {
2289                key: "acme-auth-key".to_string(),
2290            },
2291        );
2292        (dir, auth)
2293    }
2294
2295    fn expected_env_pair() -> (String, String) {
2296        let key = ["PATH", "HOME", "PWD"]
2297            .iter()
2298            .find_map(|k| {
2299                std::env::var(k)
2300                    .ok()
2301                    .filter(|v| !v.is_empty())
2302                    .map(|v| ((*k).to_string(), v))
2303            })
2304            .expect("expected at least one non-empty environment variable");
2305        (key.0, key.1)
2306    }
2307
2308    #[test]
2309    fn parse_legacy_generated_models_extracts_known_legacy_only_providers() {
2310        let parsed = parse_legacy_generated_models();
2311        if LEGACY_MODELS_GENERATED_TS.contains("export const MODELS = {} as const;") {
2312            assert!(
2313                parsed.is_empty(),
2314                "published stub catalog should not parse into legacy entries"
2315            );
2316            return;
2317        }
2318        assert!(
2319            !parsed.is_empty(),
2320            "legacy generated model catalog should parse into entries"
2321        );
2322
2323        assert!(
2324            parsed
2325                .iter()
2326                .any(|m| m.provider == "azure-openai-responses")
2327        );
2328        assert!(parsed.iter().any(|m| m.provider == "vercel-ai-gateway"));
2329        assert!(parsed.iter().any(|m| m.provider == "kimi-coding"));
2330    }
2331
2332    #[test]
2333    fn built_in_models_include_all_legacy_provider_model_pairs() {
2334        let (_dir, auth) = test_auth_storage();
2335        let built = built_in_models(&auth, ModelRegistryLoadMode::Full);
2336
2337        let built_keys: HashSet<(String, String)> = built
2338            .iter()
2339            .map(|entry| {
2340                (
2341                    entry.model.provider.to_ascii_lowercase(),
2342                    entry.model.id.to_ascii_lowercase(),
2343                )
2344            })
2345            .collect();
2346
2347        let mut missing = Vec::new();
2348        for legacy in legacy_generated_models() {
2349            let normalized_id = canonicalize_model_id_for_provider(&legacy.provider, &legacy.id);
2350            if normalized_id.is_empty() {
2351                continue;
2352            }
2353            let key = (
2354                legacy.provider.to_ascii_lowercase(),
2355                normalized_id.to_ascii_lowercase(),
2356            );
2357            if !built_keys.contains(&key) {
2358                missing.push(format!("{}/{}", legacy.provider, legacy.id));
2359            }
2360        }
2361
2362        assert!(
2363            missing.is_empty(),
2364            "missing legacy provider/model entries in built-in registry: {}",
2365            missing.join(", ")
2366        );
2367    }
2368
2369    #[test]
2370    fn built_in_models_preserve_legacy_model_display_names() {
2371        let (_dir, auth) = test_auth_storage();
2372        let built = built_in_models(&auth, ModelRegistryLoadMode::Full);
2373
2374        let name_by_key: HashMap<(String, String), String> = built
2375            .iter()
2376            .map(|entry| {
2377                (
2378                    (
2379                        entry.model.provider.to_ascii_lowercase(),
2380                        entry.model.id.to_ascii_lowercase(),
2381                    ),
2382                    entry.model.name.clone(),
2383                )
2384            })
2385            .collect();
2386
2387        let mut mismatches = Vec::new();
2388        for legacy in legacy_generated_models() {
2389            let normalized_id = canonicalize_model_id_for_provider(&legacy.provider, &legacy.id);
2390            if normalized_id.is_empty() {
2391                continue;
2392            }
2393            let key = (
2394                legacy.provider.to_ascii_lowercase(),
2395                normalized_id.to_ascii_lowercase(),
2396            );
2397            let Some(built_name) = name_by_key.get(&key) else {
2398                continue;
2399            };
2400            if !legacy.name.trim().is_empty() && built_name != &legacy.name {
2401                mismatches.push(format!(
2402                    "{}/{} => expected {:?}, got {:?}",
2403                    legacy.provider, legacy.id, legacy.name, built_name
2404                ));
2405            }
2406        }
2407
2408        assert!(
2409            mismatches.is_empty(),
2410            "legacy model display name mismatches: {}",
2411            mismatches.join("; ")
2412        );
2413    }
2414
2415    #[test]
2416    fn built_in_models_include_core_provider_entries() {
2417        let (_dir, auth) = test_auth_storage();
2418        let models = built_in_models(&auth, ModelRegistryLoadMode::Full);
2419
2420        assert!(
2421            models.iter().any(
2422                |m| m.model.provider == "anthropic" && m.model.id == "claude-sonnet-4-20250514"
2423            )
2424        );
2425        assert!(
2426            models
2427                .iter()
2428                .any(|m| m.model.provider == "openai" && m.model.id == "gpt-4o")
2429        );
2430        assert!(
2431            models
2432                .iter()
2433                .any(|m| m.model.provider == "openai" && m.model.id == "gpt-5.4")
2434        );
2435        assert!(
2436            models
2437                .iter()
2438                .any(|m| m.model.provider == "google" && m.model.id == "gemini-2.5-pro")
2439        );
2440        assert!(
2441            models
2442                .iter()
2443                .any(|m| m.model.provider == "openrouter" && m.model.id == "openrouter/auto")
2444        );
2445
2446        let anthropic = models
2447            .iter()
2448            .find(|m| m.model.provider == "anthropic")
2449            .expect("anthropic model");
2450        let openai = models
2451            .iter()
2452            .find(|m| m.model.provider == "openai")
2453            .expect("openai model");
2454        let google = models
2455            .iter()
2456            .find(|m| m.model.provider == "google")
2457            .expect("google model");
2458        let openrouter = models
2459            .iter()
2460            .find(|m| m.model.provider == "openrouter")
2461            .expect("openrouter model");
2462        assert_eq!(anthropic.api_key.as_deref(), Some("anthropic-auth-key"));
2463        assert_eq!(openai.api_key.as_deref(), Some("openai-auth-key"));
2464        assert_eq!(google.api_key.as_deref(), Some("google-auth-key"));
2465        assert_eq!(openrouter.api_key.as_deref(), Some("openrouter-auth-key"));
2466    }
2467
2468    #[test]
2469    fn built_in_models_include_oauth_provider_entries() {
2470        let (_dir, auth) = test_auth_storage();
2471        let models = built_in_models(&auth, ModelRegistryLoadMode::Full);
2472
2473        assert!(models.iter().any(|m| {
2474            m.model.provider == "openai-codex"
2475                && m.model.api == "openai-codex-responses"
2476                && m.model.id == "gpt-5.4"
2477        }));
2478        assert!(models.iter().any(|m| {
2479            m.model.provider == "openai-codex"
2480                && m.model.api == "openai-codex-responses"
2481                && m.model.id == "gpt-5.2-codex"
2482        }));
2483        assert!(models.iter().any(|m| {
2484            m.model.provider == "google-gemini-cli"
2485                && m.model.api == "google-gemini-cli"
2486                && m.model.id == "gemini-2.5-pro"
2487        }));
2488        assert!(models.iter().any(|m| {
2489            m.model.provider == "google-antigravity"
2490                && m.model.api == "google-gemini-cli"
2491                && m.model.id == "gemini-3-flash"
2492        }));
2493    }
2494
2495    #[test]
2496    fn built_in_models_include_non_legacy_provider_model_strings_from_snapshot() {
2497        let (_dir, auth) = test_auth_storage();
2498        let models = built_in_models(&auth, ModelRegistryLoadMode::Full);
2499
2500        assert!(
2501            models
2502                .iter()
2503                .any(|m| { m.model.provider == "groq" && m.model.id == "llama-3.3-70b-versatile" })
2504        );
2505        assert!(
2506            models
2507                .iter()
2508                .any(|m| { m.model.provider == "zhipuai" && m.model.id == "glm-4.6" })
2509        );
2510        assert!(models.iter().any(|m| {
2511            m.model.provider == "openrouter" && m.model.id == "anthropic/claude-sonnet-4"
2512        }));
2513    }
2514
2515    #[test]
2516    fn built_in_models_seed_gitlab_upstream_entries_with_gitlab_chat_api() {
2517        let (_dir, auth) = test_auth_storage();
2518        let models = built_in_models(&auth, ModelRegistryLoadMode::Full);
2519
2520        let gitlab = models
2521            .iter()
2522            .find(|m| m.model.provider == "gitlab" && m.model.id == "duo-chat-gpt-5-1")
2523            .expect("gitlab upstream model");
2524        assert_eq!(gitlab.model.api, "gitlab-chat");
2525        assert!(gitlab.auth_header);
2526    }
2527
2528    #[test]
2529    fn autocomplete_candidates_include_legacy_and_latest_entries() {
2530        let candidates = model_autocomplete_candidates();
2531        assert!(
2532            candidates
2533                .iter()
2534                .any(|candidate| candidate.slug == "openai-codex/gpt-5.4")
2535        );
2536        assert!(
2537            candidates
2538                .iter()
2539                .any(|candidate| candidate.slug == "openai-codex/gpt-5.2-codex")
2540        );
2541        assert!(
2542            candidates
2543                .iter()
2544                .any(|candidate| candidate.slug == "google-gemini-cli/gemini-2.5-pro")
2545        );
2546        assert!(
2547            candidates
2548                .iter()
2549                .any(|candidate| candidate.slug == "openai/gpt-5.4")
2550        );
2551        assert!(
2552            candidates
2553                .iter()
2554                .any(|candidate| candidate.slug == "anthropic/claude-opus-4-5")
2555        );
2556        assert!(
2557            candidates
2558                .iter()
2559                .any(|candidate| candidate.slug == "groq/llama-3.3-70b-versatile")
2560        );
2561        assert!(
2562            candidates
2563                .iter()
2564                .any(|candidate| candidate.slug == "openrouter/anthropic/claude-sonnet-4.6")
2565        );
2566    }
2567
2568    #[test]
2569    fn autocomplete_candidates_are_case_insensitively_unique() {
2570        let candidates = model_autocomplete_candidates();
2571        let mut seen = HashSet::new();
2572        for candidate in candidates {
2573            let key = candidate.slug.to_ascii_lowercase();
2574            assert!(
2575                seen.insert(key),
2576                "duplicate autocomplete slug (case-insensitive): {}",
2577                candidate.slug
2578            );
2579        }
2580    }
2581
2582    #[test]
2583    fn apply_custom_models_overrides_provider_fields() {
2584        let (_dir, auth) = test_auth_storage();
2585        let mut models = built_in_models(&auth, ModelRegistryLoadMode::Full);
2586        let (env_key, env_val) = expected_env_pair();
2587        let mut provider_headers = HashMap::new();
2588        provider_headers.insert("x-provider".to_string(), "provider-header".to_string());
2589
2590        let config = ModelsConfig {
2591            providers: HashMap::from([(
2592                "anthropic".to_string(),
2593                ProviderConfig {
2594                    base_url: Some("https://proxy.example/v1/messages".to_string()),
2595                    api: Some("anthropic-messages".to_string()),
2596                    api_key: Some(format!("env:{env_key}")),
2597                    headers: Some(provider_headers),
2598                    auth_header: Some(true),
2599                    compat: Some(CompatConfig {
2600                        supports_store: Some(true),
2601                        ..CompatConfig::default()
2602                    }),
2603                    models: None,
2604                },
2605            )]),
2606        };
2607
2608        apply_custom_models(&auth, &mut models, &config, None);
2609
2610        for entry in models.iter().filter(|m| m.model.provider == "anthropic") {
2611            assert_eq!(entry.model.base_url, "https://proxy.example/v1/messages");
2612            assert_eq!(entry.model.api, "anthropic-messages");
2613            assert_eq!(entry.api_key.as_deref(), Some(env_val.as_str()));
2614            assert_eq!(
2615                entry.headers.get("x-provider").map(String::as_str),
2616                Some("provider-header")
2617            );
2618            assert!(entry.auth_header);
2619            assert!(
2620                entry
2621                    .compat
2622                    .as_ref()
2623                    .and_then(|c| c.supports_store)
2624                    .unwrap_or(false)
2625            );
2626        }
2627    }
2628
2629    #[test]
2630    fn apply_custom_models_preserves_existing_headers_when_provider_header_values_unresolved() {
2631        let (dir, auth) = test_auth_storage();
2632        let mut models = vec![ModelEntry {
2633            model: Model {
2634                id: "claude-test".to_string(),
2635                name: "Claude Test".to_string(),
2636                api: "anthropic-messages".to_string(),
2637                provider: "anthropic".to_string(),
2638                base_url: "https://api.anthropic.com/v1/messages".to_string(),
2639                reasoning: false,
2640                input: vec![InputType::Text],
2641                cost: ModelCost {
2642                    input: 0.0,
2643                    output: 0.0,
2644                    cache_read: 0.0,
2645                    cache_write: 0.0,
2646                },
2647                context_window: 200_000,
2648                max_tokens: 8_192,
2649                headers: HashMap::new(),
2650            },
2651            api_key: None,
2652            headers: HashMap::from([("x-built-in".to_string(), "keep-me".to_string())]),
2653            auth_header: false,
2654            compat: None,
2655            oauth_config: None,
2656        }];
2657
2658        let config = ModelsConfig {
2659            providers: HashMap::from([(
2660                "anthropic".to_string(),
2661                ProviderConfig {
2662                    headers: Some(HashMap::from([(
2663                        "x-provider".to_string(),
2664                        "file:missing-header.txt".to_string(),
2665                    )])),
2666                    ..ProviderConfig::default()
2667                },
2668            )]),
2669        };
2670
2671        apply_custom_models(&auth, &mut models, &config, Some(dir.path()));
2672
2673        assert_eq!(
2674            models[0].headers.get("x-built-in").map(String::as_str),
2675            Some("keep-me")
2676        );
2677        assert!(
2678            !models[0].headers.contains_key("x-provider"),
2679            "unresolved provider header values should not inject empty overrides"
2680        );
2681    }
2682
2683    #[test]
2684    fn apply_custom_models_empty_provider_header_map_clears_existing_headers() {
2685        let (_dir, auth) = test_auth_storage();
2686        let mut models = vec![ModelEntry {
2687            model: Model {
2688                id: "claude-test".to_string(),
2689                name: "Claude Test".to_string(),
2690                api: "anthropic-messages".to_string(),
2691                provider: "anthropic".to_string(),
2692                base_url: "https://api.anthropic.com/v1/messages".to_string(),
2693                reasoning: false,
2694                input: vec![InputType::Text],
2695                cost: ModelCost {
2696                    input: 0.0,
2697                    output: 0.0,
2698                    cache_read: 0.0,
2699                    cache_write: 0.0,
2700                },
2701                context_window: 200_000,
2702                max_tokens: 8_192,
2703                headers: HashMap::new(),
2704            },
2705            api_key: None,
2706            headers: HashMap::from([("x-built-in".to_string(), "remove-me".to_string())]),
2707            auth_header: false,
2708            compat: None,
2709            oauth_config: None,
2710        }];
2711
2712        let config = ModelsConfig {
2713            providers: HashMap::from([(
2714                "anthropic".to_string(),
2715                ProviderConfig {
2716                    headers: Some(HashMap::new()),
2717                    ..ProviderConfig::default()
2718                },
2719            )]),
2720        };
2721
2722        apply_custom_models(&auth, &mut models, &config, None);
2723
2724        assert!(
2725            models[0].headers.is_empty(),
2726            "an explicit empty header map should still clear inherited headers"
2727        );
2728    }
2729
2730    #[test]
2731    fn apply_custom_models_uses_schema_defaults_for_provider_models() {
2732        let (_dir, auth) = test_auth_storage();
2733        let mut models = Vec::new();
2734        let config = ModelsConfig {
2735            providers: HashMap::from([(
2736                "cohere".to_string(),
2737                ProviderConfig {
2738                    models: Some(vec![ModelConfig {
2739                        id: "command-r-plus".to_string(),
2740                        ..ModelConfig::default()
2741                    }]),
2742                    ..ProviderConfig::default()
2743                },
2744            )]),
2745        };
2746
2747        apply_custom_models(&auth, &mut models, &config, None);
2748
2749        let cohere = models
2750            .iter()
2751            .find(|entry| entry.model.provider == "cohere")
2752            .expect("cohere model should be added");
2753        assert_eq!(cohere.model.api, "cohere-chat");
2754        assert_eq!(cohere.model.base_url, "https://api.cohere.com/v2");
2755        assert!(
2756            !cohere.model.reasoning,
2757            "command-r-plus is non-reasoning; command-a is the reasoning line"
2758        );
2759        assert_eq!(cohere.model.input, vec![InputType::Text]);
2760        assert_eq!(cohere.model.context_window, 128_000);
2761        assert_eq!(cohere.model.max_tokens, 8192);
2762        assert!(!cohere.auth_header);
2763    }
2764
2765    #[test]
2766    fn apply_custom_models_merges_provider_and_model_compat() {
2767        let (_dir, auth) = test_auth_storage();
2768        let mut models = Vec::new();
2769        let config = ModelsConfig {
2770            providers: HashMap::from([(
2771                "custom-openai".to_string(),
2772                ProviderConfig {
2773                    api: Some("openai-completions".to_string()),
2774                    base_url: Some("https://compat.example/v1".to_string()),
2775                    compat: Some(CompatConfig {
2776                        supports_tools: Some(false),
2777                        supports_usage_in_streaming: Some(false),
2778                        max_tokens_field: Some("max_completion_tokens".to_string()),
2779                        custom_headers: Some(HashMap::from([
2780                            ("x-provider-only".to_string(), "provider".to_string()),
2781                            ("x-shared".to_string(), "provider".to_string()),
2782                        ])),
2783                        ..CompatConfig::default()
2784                    }),
2785                    models: Some(vec![ModelConfig {
2786                        id: "custom-model".to_string(),
2787                        compat: Some(CompatConfig {
2788                            supports_tools: Some(true),
2789                            system_role_name: Some("developer".to_string()),
2790                            custom_headers: Some(HashMap::from([
2791                                ("x-model-only".to_string(), "model".to_string()),
2792                                ("x-shared".to_string(), "model".to_string()),
2793                            ])),
2794                            ..CompatConfig::default()
2795                        }),
2796                        ..ModelConfig::default()
2797                    }]),
2798                    ..ProviderConfig::default()
2799                },
2800            )]),
2801        };
2802
2803        apply_custom_models(&auth, &mut models, &config, None);
2804
2805        let entry = models
2806            .iter()
2807            .find(|m| m.model.provider == "custom-openai" && m.model.id == "custom-model")
2808            .expect("custom model should be added");
2809        let compat = entry.compat.as_ref().expect("compat should be merged");
2810        assert_eq!(
2811            compat.max_tokens_field.as_deref(),
2812            Some("max_completion_tokens")
2813        );
2814        assert_eq!(compat.system_role_name.as_deref(), Some("developer"));
2815        assert_eq!(compat.supports_usage_in_streaming, Some(false));
2816        assert_eq!(compat.supports_tools, Some(true));
2817        let custom_headers = compat
2818            .custom_headers
2819            .as_ref()
2820            .expect("custom headers should be merged");
2821        assert_eq!(
2822            custom_headers.get("x-provider-only").map(String::as_str),
2823            Some("provider")
2824        );
2825        assert_eq!(
2826            custom_headers.get("x-model-only").map(String::as_str),
2827            Some("model")
2828        );
2829        assert_eq!(
2830            custom_headers.get("x-shared").map(String::as_str),
2831            Some("model")
2832        );
2833    }
2834
2835    #[test]
2836    fn apply_custom_models_uses_schema_defaults_for_native_anthropic_models() {
2837        let (_dir, auth) = test_auth_storage();
2838        let mut models = Vec::new();
2839        let config = ModelsConfig {
2840            providers: HashMap::from([(
2841                "anthropic".to_string(),
2842                ProviderConfig {
2843                    models: Some(vec![ModelConfig {
2844                        id: "claude-schema-default".to_string(),
2845                        ..ModelConfig::default()
2846                    }]),
2847                    ..ProviderConfig::default()
2848                },
2849            )]),
2850        };
2851
2852        apply_custom_models(&auth, &mut models, &config, None);
2853
2854        let anthropic = models
2855            .iter()
2856            .find(|entry| entry.model.provider == "anthropic")
2857            .expect("anthropic model should be added");
2858        assert_eq!(anthropic.model.api, "anthropic-messages");
2859        assert_eq!(
2860            anthropic.model.base_url,
2861            "https://api.anthropic.com/v1/messages"
2862        );
2863        assert!(anthropic.model.reasoning);
2864        assert_eq!(
2865            anthropic.model.input,
2866            vec![InputType::Text, InputType::Image]
2867        );
2868        assert_eq!(anthropic.model.context_window, 200_000);
2869        assert_eq!(anthropic.model.max_tokens, 8192);
2870        assert!(!anthropic.auth_header);
2871    }
2872
2873    #[test]
2874    fn apply_custom_models_uses_native_adapter_defaults_for_codex_alias_models() {
2875        let (_dir, auth) = test_auth_storage();
2876        let mut models = Vec::new();
2877        let config = ModelsConfig {
2878            providers: HashMap::from([(
2879                "codex".to_string(),
2880                ProviderConfig {
2881                    models: Some(vec![ModelConfig {
2882                        id: "gpt-5.4".to_string(),
2883                        ..ModelConfig::default()
2884                    }]),
2885                    ..ProviderConfig::default()
2886                },
2887            )]),
2888        };
2889
2890        apply_custom_models(&auth, &mut models, &config, None);
2891
2892        let codex = models
2893            .iter()
2894            .find(|entry| entry.model.provider == "codex")
2895            .expect("codex model should be added");
2896        assert_eq!(codex.model.api, "openai-codex-responses");
2897        assert_eq!(codex.model.base_url, CODEX_RESPONSES_API_URL);
2898        assert!(codex.model.reasoning);
2899        assert_eq!(codex.model.input, vec![InputType::Text, InputType::Image]);
2900        assert_eq!(codex.model.context_window, 272_000);
2901        assert_eq!(codex.model.max_tokens, 128_000);
2902        assert!(codex.auth_header);
2903    }
2904
2905    #[test]
2906    fn apply_custom_models_uses_native_adapter_defaults_for_google_cli_alias_models() {
2907        let (_dir, auth) = test_auth_storage();
2908        let mut models = Vec::new();
2909        let config = ModelsConfig {
2910            providers: HashMap::from([
2911                (
2912                    "gemini-cli".to_string(),
2913                    ProviderConfig {
2914                        models: Some(vec![ModelConfig {
2915                            id: "gemini-2.5-pro".to_string(),
2916                            ..ModelConfig::default()
2917                        }]),
2918                        ..ProviderConfig::default()
2919                    },
2920                ),
2921                (
2922                    "antigravity".to_string(),
2923                    ProviderConfig {
2924                        models: Some(vec![ModelConfig {
2925                            id: "gemini-3-flash".to_string(),
2926                            ..ModelConfig::default()
2927                        }]),
2928                        ..ProviderConfig::default()
2929                    },
2930                ),
2931            ]),
2932        };
2933
2934        apply_custom_models(&auth, &mut models, &config, None);
2935
2936        let gemini_cli = models
2937            .iter()
2938            .find(|entry| entry.model.provider == "gemini-cli")
2939            .expect("gemini-cli model should be added");
2940        assert_eq!(gemini_cli.model.api, "google-gemini-cli");
2941        assert_eq!(gemini_cli.model.base_url, GOOGLE_GEMINI_CLI_API_URL);
2942        assert!(gemini_cli.model.reasoning);
2943        assert_eq!(
2944            gemini_cli.model.input,
2945            vec![InputType::Text, InputType::Image]
2946        );
2947        assert_eq!(gemini_cli.model.context_window, 128_000);
2948        assert_eq!(gemini_cli.model.max_tokens, 8192);
2949        assert!(gemini_cli.auth_header);
2950
2951        let antigravity = models
2952            .iter()
2953            .find(|entry| entry.model.provider == "antigravity")
2954            .expect("antigravity model should be added");
2955        assert_eq!(antigravity.model.api, "google-gemini-cli");
2956        assert_eq!(antigravity.model.base_url, GOOGLE_ANTIGRAVITY_API_URL);
2957        assert!(antigravity.model.reasoning);
2958        assert_eq!(
2959            antigravity.model.input,
2960            vec![InputType::Text, InputType::Image]
2961        );
2962        assert_eq!(antigravity.model.context_window, 128_000);
2963        assert_eq!(antigravity.model.max_tokens, 8192);
2964        assert!(antigravity.auth_header);
2965    }
2966
2967    #[test]
2968    fn apply_custom_models_alias_resolves_canonical_provider_api_key() {
2969        let (_dir, mut auth) = test_auth_storage();
2970        auth.set(
2971            "moonshotai",
2972            AuthCredential::ApiKey {
2973                key: "moonshot-auth-key".to_string(),
2974            },
2975        );
2976
2977        let mut models = Vec::new();
2978        let config = ModelsConfig {
2979            providers: HashMap::from([(
2980                "kimi".to_string(),
2981                ProviderConfig {
2982                    models: Some(vec![ModelConfig {
2983                        id: "kimi-k2-instruct".to_string(),
2984                        ..ModelConfig::default()
2985                    }]),
2986                    ..ProviderConfig::default()
2987                },
2988            )]),
2989        };
2990
2991        apply_custom_models(&auth, &mut models, &config, None);
2992
2993        let kimi = models
2994            .iter()
2995            .find(|entry| entry.model.provider == "kimi")
2996            .expect("kimi model should be added");
2997        assert_eq!(kimi.model.api, "openai-completions");
2998        assert_eq!(kimi.model.base_url, "https://api.moonshot.ai/v1");
2999        assert_eq!(kimi.api_key.as_deref(), Some("moonshot-auth-key"));
3000        assert!(kimi.auth_header);
3001    }
3002
3003    #[test]
3004    fn model_registry_find_and_find_by_id_work() {
3005        let (_dir, auth) = test_auth_storage();
3006        let registry = ModelRegistry::load(&auth, None);
3007
3008        let by_provider_and_id = registry
3009            .find("openai", "gpt-4o")
3010            .expect("openai/gpt-4o should exist");
3011        assert_eq!(by_provider_and_id.model.provider, "openai");
3012        assert_eq!(by_provider_and_id.model.id, "gpt-4o");
3013
3014        let by_id = registry
3015            .find_by_id("claude-opus-4-5")
3016            .expect("claude-opus-4-5 should exist");
3017        assert_eq!(by_id.model.provider, "anthropic");
3018        assert_eq!(by_id.model.id, "claude-opus-4-5");
3019
3020        assert!(registry.find("openai", "does-not-exist").is_none());
3021        assert!(registry.find_by_id("does-not-exist").is_none());
3022    }
3023
3024    #[test]
3025    fn model_registry_find_by_id_is_case_insensitive() {
3026        let (_dir, auth) = test_auth_storage();
3027        let registry = ModelRegistry::load(&auth, None);
3028
3029        let by_id = registry
3030            .find_by_id("GPT-5.2-CODEX")
3031            .expect("gpt-5.2-codex should resolve case-insensitively");
3032        assert_eq!(by_id.model.id, "gpt-5.2-codex");
3033    }
3034
3035    #[test]
3036    fn model_registry_finds_latest_openai_codex_seed() {
3037        let (_dir, auth) = test_auth_storage();
3038        let registry = ModelRegistry::load(&auth, None);
3039
3040        let by_provider = registry
3041            .find("openai-codex", "GPT-5.4")
3042            .expect("gpt-5.4 codex should resolve case-insensitively");
3043        assert_eq!(by_provider.model.provider, "openai-codex");
3044        assert_eq!(by_provider.model.id, "gpt-5.4");
3045    }
3046
3047    #[test]
3048    fn model_registry_find_normalizes_openrouter_model_aliases() {
3049        let (_dir, auth) = test_auth_storage();
3050        let registry = ModelRegistry::load(&auth, None);
3051
3052        let gpt4o_mini = registry
3053            .find("openrouter", "gpt-4o-mini")
3054            .expect("openrouter alias should resolve");
3055        assert_eq!(gpt4o_mini.model.provider, "openrouter");
3056        assert_eq!(gpt4o_mini.model.id, "openai/gpt-4o-mini");
3057
3058        let auto = registry
3059            .find("openrouter", "auto")
3060            .expect("openrouter auto alias should resolve");
3061        assert_eq!(auto.model.id, "openrouter/auto");
3062
3063        let provider_alias = registry
3064            .find("open-router", "gpt-4o-mini")
3065            .expect("open-router provider alias should resolve");
3066        assert_eq!(provider_alias.model.provider, "openrouter");
3067        assert_eq!(provider_alias.model.id, "openai/gpt-4o-mini");
3068    }
3069
3070    #[test]
3071    fn ad_hoc_model_entry_normalizes_openrouter_aliases() {
3072        let auto = ad_hoc_model_entry("openrouter", "auto").expect("openrouter auto ad-hoc");
3073        assert_eq!(auto.model.id, "openrouter/auto");
3074
3075        let gpt4o_mini =
3076            ad_hoc_model_entry("openrouter", "gpt-4o-mini").expect("openrouter gpt-4o-mini ad-hoc");
3077        assert_eq!(gpt4o_mini.model.id, "openai/gpt-4o-mini");
3078    }
3079
3080    #[test]
3081    fn model_registry_merge_entries_deduplicates() {
3082        let (_dir, auth) = test_auth_storage();
3083        let mut registry = ModelRegistry::load(&auth, None);
3084        let before = registry.models().len();
3085        let duplicate = registry
3086            .find("openai", "gpt-4o")
3087            .expect("expected built-in openai model");
3088
3089        let new_entry = ModelEntry {
3090            model: Model {
3091                id: "acme-chat".to_string(),
3092                name: "Acme Chat".to_string(),
3093                api: "openai-completions".to_string(),
3094                provider: "acme".to_string(),
3095                base_url: "https://acme.example/v1".to_string(),
3096                reasoning: true,
3097                input: vec![InputType::Text],
3098                cost: ModelCost {
3099                    input: 0.0,
3100                    output: 0.0,
3101                    cache_read: 0.0,
3102                    cache_write: 0.0,
3103                },
3104                context_window: 64_000,
3105                max_tokens: 4096,
3106                headers: HashMap::new(),
3107            },
3108            api_key: Some("acme-auth-key".to_string()),
3109            headers: HashMap::new(),
3110            auth_header: true,
3111            compat: None,
3112            oauth_config: None,
3113        };
3114
3115        registry.merge_entries(vec![duplicate, new_entry]);
3116        assert_eq!(registry.models().len(), before + 1);
3117        assert!(registry.find("acme", "acme-chat").is_some());
3118    }
3119
3120    #[test]
3121    fn model_registry_merge_entries_deduplicates_alias_and_case_variants() {
3122        let (_dir, auth) = test_auth_storage();
3123        let mut registry = ModelRegistry::load(&auth, None);
3124        let before = registry.models().len();
3125
3126        let source = registry
3127            .find("openrouter", "gpt-4o-mini")
3128            .or_else(|| registry.find("openrouter", "openai/gpt-4o-mini"))
3129            .expect("expected built-in openrouter gpt-4o-mini model");
3130
3131        let mut alias_case_variant = source.clone();
3132        alias_case_variant.model.provider = "open-router".to_string();
3133        alias_case_variant.model.id = source.model.id.to_ascii_uppercase();
3134
3135        registry.merge_entries(vec![alias_case_variant]);
3136        assert_eq!(registry.models().len(), before);
3137    }
3138
3139    #[test]
3140    fn apply_custom_models_dedupes_openrouter_alias_conflicts() {
3141        let (_dir, auth) = test_auth_storage();
3142        let mut models = Vec::new();
3143        let config = ModelsConfig {
3144            providers: HashMap::from([(
3145                "openrouter".to_string(),
3146                ProviderConfig {
3147                    models: Some(vec![
3148                        ModelConfig {
3149                            id: "gpt-4o-mini".to_string(),
3150                            ..ModelConfig::default()
3151                        },
3152                        ModelConfig {
3153                            id: "openai/gpt-4o-mini".to_string(),
3154                            ..ModelConfig::default()
3155                        },
3156                        ModelConfig {
3157                            id: "auto".to_string(),
3158                            ..ModelConfig::default()
3159                        },
3160                    ]),
3161                    ..ProviderConfig::default()
3162                },
3163            )]),
3164        };
3165
3166        apply_custom_models(&auth, &mut models, &config, None);
3167
3168        let openrouter_models: Vec<&ModelEntry> = models
3169            .iter()
3170            .filter(|entry| entry.model.provider == "openrouter")
3171            .collect();
3172        assert_eq!(openrouter_models.len(), 2);
3173        assert!(
3174            openrouter_models
3175                .iter()
3176                .any(|entry| entry.model.id == "openai/gpt-4o-mini")
3177        );
3178        assert!(
3179            openrouter_models
3180                .iter()
3181                .any(|entry| entry.model.id == "openrouter/auto")
3182        );
3183    }
3184
3185    #[test]
3186    fn resolve_value_supports_env_and_file_prefixes() {
3187        let (env_key, env_val) = expected_env_pair();
3188        assert_eq!(
3189            resolve_value(&format!("env:{env_key}")).as_deref(),
3190            Some(env_val.as_str())
3191        );
3192
3193        let dir = tempdir().expect("tempdir");
3194        let key_path = dir.path().join("api_key.txt");
3195        std::fs::write(&key_path, "file-key\n").expect("write key file");
3196        assert_eq!(
3197            resolve_value(&format!("file:{}", key_path.display())).as_deref(),
3198            Some("file-key")
3199        );
3200        assert!(resolve_value("file:/definitely/missing/path").is_none());
3201    }
3202
3203    // ─── pi parity: bare *_API_KEY env-var indirection (issue #64) ───────────
3204
3205    #[test]
3206    fn looks_like_api_key_env_var_accepts_typical_names() {
3207        assert!(looks_like_api_key_env_var("DASHSCOPE_API_KEY"));
3208        assert!(looks_like_api_key_env_var("OPENAI_API_KEY"));
3209        assert!(looks_like_api_key_env_var("ANTHROPIC_API_KEY"));
3210        assert!(looks_like_api_key_env_var("MY_CUSTOM_API_KEY"));
3211        // Digits in the prefix are fine, as long as it starts with a letter.
3212        assert!(looks_like_api_key_env_var("PROVIDER42_API_KEY"));
3213    }
3214
3215    #[test]
3216    fn looks_like_api_key_env_var_rejects_non_matches() {
3217        // Wrong suffix.
3218        assert!(!looks_like_api_key_env_var("DASHSCOPE_API"));
3219        assert!(!looks_like_api_key_env_var("DASHSCOPE_TOKEN"));
3220        // Lowercase letters anywhere → looks like a literal key.
3221        assert!(!looks_like_api_key_env_var("dashscope_api_key"));
3222        assert!(!looks_like_api_key_env_var("My_API_KEY"));
3223        // Real-shaped keys.
3224        assert!(!looks_like_api_key_env_var("sk-ant-api03-AAAA_API_KEY"));
3225        assert!(!looks_like_api_key_env_var("sk-1234567890"));
3226        // Bare suffix only.
3227        assert!(!looks_like_api_key_env_var("_API_KEY"));
3228        assert!(!looks_like_api_key_env_var(""));
3229        // Must start with a letter.
3230        assert!(!looks_like_api_key_env_var("0DASH_API_KEY"));
3231    }
3232
3233    #[test]
3234    fn resolve_value_resolves_bare_api_key_env_var_when_set() {
3235        let resolved = resolve_value_with_resolvers("DASHSCOPE_API_KEY", None, |var| {
3236            assert_eq!(var, "DASHSCOPE_API_KEY");
3237            Some("sk-real-secret-from-env".to_string())
3238        });
3239        assert_eq!(resolved.as_deref(), Some("sk-real-secret-from-env"));
3240    }
3241
3242    #[test]
3243    fn resolve_value_trims_whitespace_from_resolved_env_value() {
3244        let resolved = resolve_value_with_resolvers("DASHSCOPE_API_KEY", None, |_| {
3245            Some("  sk-trimmed  \n".to_string())
3246        });
3247        assert_eq!(resolved.as_deref(), Some("sk-trimmed"));
3248    }
3249
3250    #[test]
3251    fn resolve_value_falls_back_to_literal_when_referenced_env_var_unset() {
3252        // When the env var is unset we keep the literal so existing
3253        // configurations that just happened to choose an `_API_KEY`-shaped
3254        // value continue to work; the user sees the auth failure as before.
3255        let resolved = resolve_value_with_resolvers("UNSET_PROVIDER_API_KEY", None, |_| None);
3256        assert_eq!(resolved.as_deref(), Some("UNSET_PROVIDER_API_KEY"));
3257    }
3258
3259    #[test]
3260    fn resolve_value_falls_back_to_literal_when_referenced_env_var_empty() {
3261        let resolved =
3262            resolve_value_with_resolvers("DASHSCOPE_API_KEY", None, |_| Some("   ".to_string()));
3263        assert_eq!(resolved.as_deref(), Some("DASHSCOPE_API_KEY"));
3264    }
3265
3266    #[test]
3267    fn resolve_value_treats_literal_key_unchanged() {
3268        // Real-looking provider keys are passed through verbatim and must NOT
3269        // hit the env_lookup closure.
3270        let resolved = resolve_value_with_resolvers("sk-ant-api03-abcdef123", None, |_| {
3271            panic!("env_lookup should not be invoked for literal-shaped values");
3272        });
3273        assert_eq!(resolved.as_deref(), Some("sk-ant-api03-abcdef123"));
3274    }
3275
3276    #[test]
3277    fn model_registry_load_reads_models_json_and_applies_config() {
3278        let (dir, auth) = test_auth_storage();
3279        let models_path = dir.path().join("models.json");
3280        let key_path = dir.path().join("custom_key.txt");
3281        std::fs::write(&key_path, "acme-file-key\n").expect("write custom key");
3282
3283        let models_json = serde_json::json!({
3284            "providers": {
3285                "acme": {
3286                    "baseUrl": "https://acme.example/v1",
3287                    "api": "openai-completions",
3288                    "apiKey": format!("file:{}", key_path.display()),
3289                    "headers": {
3290                        "x-provider": "provider-level"
3291                    },
3292                    "authHeader": true,
3293                    "models": [
3294                        {
3295                            "id": "acme-chat",
3296                            "name": "Acme Chat",
3297                            "input": ["text", "image"],
3298                            "reasoning": true,
3299                            "contextWindow": 64000,
3300                            "maxTokens": 4096,
3301                            "headers": {
3302                                "x-model": "model-level"
3303                            }
3304                        }
3305                    ]
3306                }
3307            }
3308        });
3309
3310        std::fs::write(
3311            &models_path,
3312            serde_json::to_string_pretty(&models_json).expect("serialize models json"),
3313        )
3314        .expect("write models.json");
3315
3316        let registry = ModelRegistry::load(&auth, Some(models_path));
3317        let acme = registry
3318            .find("acme", "acme-chat")
3319            .expect("custom acme model should load from models.json");
3320
3321        assert_eq!(acme.model.name, "Acme Chat");
3322        assert_eq!(acme.model.api, "openai-completions");
3323        assert_eq!(acme.model.base_url, "https://acme.example/v1");
3324        assert_eq!(acme.model.context_window, 64_000);
3325        assert_eq!(acme.model.max_tokens, 4096);
3326        assert_eq!(acme.api_key.as_deref(), Some("acme-file-key"));
3327        assert!(acme.auth_header);
3328        assert_eq!(
3329            acme.headers.get("x-provider").map(String::as_str),
3330            Some("provider-level")
3331        );
3332        assert_eq!(
3333            acme.headers.get("x-model").map(String::as_str),
3334            Some("model-level")
3335        );
3336        assert_eq!(acme.model.input, vec![InputType::Text, InputType::Image]);
3337    }
3338
3339    #[test]
3340    fn model_registry_load_resolves_relative_file_values_against_models_json_dir() {
3341        let (dir, auth) = test_auth_storage();
3342        let models_dir = dir.path().join("config");
3343        std::fs::create_dir_all(&models_dir).expect("create models dir");
3344        let models_path = models_dir.join("models.json");
3345        std::fs::write(models_dir.join("relative_key.txt"), "relative-api-key\n")
3346            .expect("write relative key");
3347        std::fs::write(
3348            models_dir.join("provider_header.txt"),
3349            "provider-from-file\n",
3350        )
3351        .expect("write provider header");
3352        std::fs::write(models_dir.join("model_header.txt"), "model-from-file\n")
3353            .expect("write model header");
3354
3355        let models_json = serde_json::json!({
3356            "providers": {
3357                "acme-relative": {
3358                    "baseUrl": "https://acme.example/v1",
3359                    "api": "openai-completions",
3360                    "apiKey": "file:relative_key.txt",
3361                    "headers": {
3362                        "x-provider-file": "file:provider_header.txt"
3363                    },
3364                    "models": [
3365                        {
3366                            "id": "acme-relative-chat",
3367                            "headers": {
3368                                "x-model-file": "file:model_header.txt"
3369                            }
3370                        }
3371                    ]
3372                }
3373            }
3374        });
3375
3376        std::fs::write(
3377            &models_path,
3378            serde_json::to_string_pretty(&models_json).expect("serialize models json"),
3379        )
3380        .expect("write models.json");
3381
3382        let registry = ModelRegistry::load(&auth, Some(models_path));
3383        let acme = registry
3384            .find("acme-relative", "acme-relative-chat")
3385            .expect("custom model should load with relative file-backed values");
3386
3387        assert_eq!(acme.api_key.as_deref(), Some("relative-api-key"));
3388        assert_eq!(
3389            acme.headers.get("x-provider-file").map(String::as_str),
3390            Some("provider-from-file")
3391        );
3392        assert_eq!(
3393            acme.headers.get("x-model-file").map(String::as_str),
3394            Some("model-from-file")
3395        );
3396    }
3397
3398    // ─── supports_xhigh ──────────────────────────────────────────────
3399
3400    fn make_model_entry(id: &str, reasoning: bool) -> ModelEntry {
3401        ModelEntry {
3402            model: Model {
3403                id: id.to_string(),
3404                name: id.to_string(),
3405                api: "openai-responses".to_string(),
3406                provider: "test".to_string(),
3407                base_url: "https://example.com".to_string(),
3408                reasoning,
3409                input: vec![InputType::Text],
3410                cost: ModelCost {
3411                    input: 0.0,
3412                    output: 0.0,
3413                    cache_read: 0.0,
3414                    cache_write: 0.0,
3415                },
3416                context_window: 128_000,
3417                max_tokens: 8192,
3418                headers: HashMap::new(),
3419            },
3420            api_key: None,
3421            headers: HashMap::new(),
3422            auth_header: false,
3423            compat: None,
3424            oauth_config: None,
3425        }
3426    }
3427
3428    #[test]
3429    fn supports_xhigh_for_known_models() {
3430        assert!(make_model_entry("gpt-5.1-codex-max", true).supports_xhigh());
3431        assert!(make_model_entry("gpt-5.2", true).supports_xhigh());
3432        assert!(make_model_entry("gpt-5.4", true).supports_xhigh());
3433        assert!(make_model_entry("gpt-5.2-codex", true).supports_xhigh());
3434        assert!(make_model_entry("gpt-5.3-codex", true).supports_xhigh());
3435        assert!(make_model_entry("gpt-5.3-codex-spark", true).supports_xhigh());
3436    }
3437
3438    #[test]
3439    fn supports_xhigh_false_for_other_models() {
3440        assert!(!make_model_entry("gpt-4o", true).supports_xhigh());
3441        assert!(!make_model_entry("claude-sonnet-4-20250514", true).supports_xhigh());
3442        assert!(!make_model_entry("gemini-2.5-pro", true).supports_xhigh());
3443    }
3444
3445    #[test]
3446    fn available_thinking_levels_non_reasoning_is_off_only() {
3447        use crate::model::ThinkingLevel;
3448        let entry = make_model_entry("gpt-4o-mini", false);
3449        assert_eq!(entry.available_thinking_levels(), vec![ThinkingLevel::Off]);
3450    }
3451
3452    #[test]
3453    fn available_thinking_levels_reasoning_without_xhigh_stops_at_high() {
3454        use crate::model::ThinkingLevel;
3455        let entry = make_model_entry("claude-sonnet-4-20250514", true);
3456        assert_eq!(
3457            entry.available_thinking_levels(),
3458            vec![
3459                ThinkingLevel::Off,
3460                ThinkingLevel::Minimal,
3461                ThinkingLevel::Low,
3462                ThinkingLevel::Medium,
3463                ThinkingLevel::High,
3464            ]
3465        );
3466    }
3467
3468    #[test]
3469    fn available_thinking_levels_reasoning_with_xhigh_includes_xhigh() {
3470        use crate::model::ThinkingLevel;
3471        let entry = make_model_entry("gpt-5.2", true);
3472        assert_eq!(
3473            entry.available_thinking_levels(),
3474            vec![
3475                ThinkingLevel::Off,
3476                ThinkingLevel::Minimal,
3477                ThinkingLevel::Low,
3478                ThinkingLevel::Medium,
3479                ThinkingLevel::High,
3480                ThinkingLevel::XHigh,
3481            ]
3482        );
3483    }
3484
3485    // ─── clamp_thinking_level ────────────────────────────────────────
3486
3487    #[test]
3488    fn clamp_non_reasoning_always_off() {
3489        use crate::model::ThinkingLevel;
3490        let entry = make_model_entry("gpt-4o-mini", false);
3491        assert_eq!(
3492            entry.clamp_thinking_level(ThinkingLevel::High),
3493            ThinkingLevel::Off
3494        );
3495        assert_eq!(
3496            entry.clamp_thinking_level(ThinkingLevel::Medium),
3497            ThinkingLevel::Off
3498        );
3499        assert_eq!(
3500            entry.clamp_thinking_level(ThinkingLevel::Off),
3501            ThinkingLevel::Off
3502        );
3503    }
3504
3505    #[test]
3506    fn clamp_xhigh_downgraded_without_support() {
3507        use crate::model::ThinkingLevel;
3508        let entry = make_model_entry("claude-sonnet-4-20250514", true);
3509        assert_eq!(
3510            entry.clamp_thinking_level(ThinkingLevel::XHigh),
3511            ThinkingLevel::High,
3512        );
3513    }
3514
3515    #[test]
3516    fn clamp_xhigh_preserved_with_support() {
3517        use crate::model::ThinkingLevel;
3518        let entry = make_model_entry("gpt-5.2", true);
3519        assert_eq!(
3520            entry.clamp_thinking_level(ThinkingLevel::XHigh),
3521            ThinkingLevel::XHigh,
3522        );
3523    }
3524
3525    #[test]
3526    fn clamp_passthrough_for_regular_levels() {
3527        use crate::model::ThinkingLevel;
3528        let entry = make_model_entry("claude-sonnet-4-20250514", true);
3529        assert_eq!(
3530            entry.clamp_thinking_level(ThinkingLevel::High),
3531            ThinkingLevel::High
3532        );
3533        assert_eq!(
3534            entry.clamp_thinking_level(ThinkingLevel::Medium),
3535            ThinkingLevel::Medium
3536        );
3537        assert_eq!(
3538            entry.clamp_thinking_level(ThinkingLevel::Low),
3539            ThinkingLevel::Low
3540        );
3541        assert_eq!(
3542            entry.clamp_thinking_level(ThinkingLevel::Minimal),
3543            ThinkingLevel::Minimal
3544        );
3545        assert_eq!(
3546            entry.clamp_thinking_level(ThinkingLevel::Off),
3547            ThinkingLevel::Off
3548        );
3549    }
3550
3551    // ─── ad_hoc_provider_defaults ────────────────────────────────────
3552
3553    #[test]
3554    fn ad_hoc_known_providers() {
3555        let providers = [
3556            "anthropic",
3557            "openai",
3558            "google",
3559            "cohere",
3560            "amazon-bedrock",
3561            "groq",
3562            "deepinfra",
3563            "cerebras",
3564            "openrouter",
3565            "mistral",
3566            "deepseek",
3567            "fireworks",
3568            "togetherai",
3569            "perplexity",
3570            "xai",
3571            "baseten",
3572            "llama",
3573            "lmstudio",
3574            "ollama-cloud",
3575        ];
3576        for provider in providers {
3577            assert!(
3578                ad_hoc_provider_defaults(provider).is_some(),
3579                "expected defaults for '{provider}'"
3580            );
3581        }
3582    }
3583
3584    #[test]
3585    fn ad_hoc_alibaba_aliases() {
3586        for alias in ["alibaba", "dashscope", "qwen"] {
3587            let defaults = ad_hoc_provider_defaults(alias)
3588                .unwrap_or_else(|| unreachable!("expected defaults for '{alias}'"));
3589            assert!(defaults.base_url.contains("dashscope"));
3590        }
3591    }
3592
3593    #[test]
3594    fn ad_hoc_moonshot_aliases() {
3595        for alias in ["moonshotai", "moonshot", "kimi"] {
3596            let defaults = ad_hoc_provider_defaults(alias)
3597                .unwrap_or_else(|| unreachable!("expected defaults for '{alias}'"));
3598            assert!(defaults.base_url.contains("moonshot"));
3599        }
3600    }
3601
3602    #[test]
3603    fn ad_hoc_batch_b1_defaults_resolve_expected_routes() {
3604        let alibaba_cn =
3605            ad_hoc_provider_defaults("alibaba-cn").expect("expected defaults for alibaba-cn");
3606        assert_eq!(alibaba_cn.api, "openai-completions");
3607        assert!(alibaba_cn.auth_header);
3608        assert!(alibaba_cn.base_url.contains("dashscope.aliyuncs.com"));
3609
3610        let alibaba_us =
3611            ad_hoc_provider_defaults("alibaba-us").expect("expected defaults for alibaba-us");
3612        assert_eq!(alibaba_us.api, "openai-completions");
3613        assert!(alibaba_us.auth_header);
3614        assert!(alibaba_us.base_url.contains("dashscope-us.aliyuncs.com"));
3615
3616        let kimi_for_coding = ad_hoc_provider_defaults("kimi-for-coding")
3617            .expect("expected defaults for kimi-for-coding");
3618        assert_eq!(kimi_for_coding.api, "anthropic-messages");
3619        assert!(!kimi_for_coding.auth_header);
3620        assert!(kimi_for_coding.base_url.contains("api.kimi.com/coding"));
3621
3622        for provider in [
3623            "minimax",
3624            "minimax-cn",
3625            "minimax-coding-plan",
3626            "minimax-cn-coding-plan",
3627        ] {
3628            let defaults = ad_hoc_provider_defaults(provider)
3629                .unwrap_or_else(|| unreachable!("expected defaults for '{provider}'"));
3630            assert_eq!(defaults.api, "anthropic-messages");
3631            assert!(!defaults.auth_header);
3632            assert!(defaults.base_url.contains("api.minimax"));
3633        }
3634    }
3635
3636    #[test]
3637    fn ad_hoc_batch_b2_defaults_resolve_expected_routes() {
3638        let cases = [
3639            ("modelscope", "https://api-inference.modelscope.cn/v1"),
3640            ("moonshotai-cn", "https://api.moonshot.cn/v1"),
3641            ("nebius", "https://api.tokenfactory.nebius.com/v1"),
3642            (
3643                "ovhcloud",
3644                "https://oai.endpoints.kepler.ai.cloud.ovh.net/v1",
3645            ),
3646            ("scaleway", "https://api.scaleway.ai/v1"),
3647        ];
3648        for (provider, expected_base_url) in &cases {
3649            let defaults = ad_hoc_provider_defaults(provider)
3650                .unwrap_or_else(|| unreachable!("expected defaults for '{provider}'"));
3651            assert_eq!(defaults.api, "openai-completions");
3652            assert!(defaults.auth_header);
3653            assert_eq!(defaults.base_url, *expected_base_url);
3654        }
3655    }
3656
3657    #[test]
3658    fn ad_hoc_batch_b3_defaults_resolve_expected_routes() {
3659        let cases = [
3660            ("siliconflow", "https://api.siliconflow.com/v1"),
3661            ("siliconflow-cn", "https://api.siliconflow.cn/v1"),
3662            ("upstage", "https://api.upstage.ai/v1/solar"),
3663            ("venice", "https://api.venice.ai/api/v1"),
3664            ("zai", "https://api.z.ai/api/paas/v4"),
3665            ("zai-coding-plan", "https://api.z.ai/api/coding/paas/v4"),
3666            ("zhipuai", "https://open.bigmodel.cn/api/paas/v4"),
3667            (
3668                "zhipuai-coding-plan",
3669                "https://open.bigmodel.cn/api/coding/paas/v4",
3670            ),
3671        ];
3672        for (provider, expected_base_url) in &cases {
3673            let defaults = ad_hoc_provider_defaults(provider)
3674                .unwrap_or_else(|| unreachable!("expected defaults for '{provider}'"));
3675            assert_eq!(defaults.api, "openai-completions");
3676            assert!(defaults.auth_header);
3677            assert_eq!(defaults.base_url, *expected_base_url);
3678        }
3679    }
3680
3681    #[test]
3682    fn ad_hoc_batch_b3_coding_plan_and_regional_variants_remain_distinct() {
3683        let siliconflow = ad_hoc_provider_defaults("siliconflow").expect("siliconflow defaults");
3684        let siliconflow_cn =
3685            ad_hoc_provider_defaults("siliconflow-cn").expect("siliconflow-cn defaults");
3686        assert_eq!(canonical_provider_id("siliconflow"), Some("siliconflow"));
3687        assert_eq!(
3688            canonical_provider_id("siliconflow-cn"),
3689            Some("siliconflow-cn")
3690        );
3691        assert_ne!(siliconflow.base_url, siliconflow_cn.base_url);
3692
3693        let zai = ad_hoc_provider_defaults("zai").expect("zai defaults");
3694        let zai_coding = ad_hoc_provider_defaults("zai-coding-plan").expect("zai-coding defaults");
3695        assert_eq!(canonical_provider_id("zai"), Some("zai"));
3696        assert_eq!(
3697            canonical_provider_id("zai-coding-plan"),
3698            Some("zai-coding-plan")
3699        );
3700        assert_eq!(zai.api, "openai-completions");
3701        assert_eq!(zai_coding.api, "openai-completions");
3702        assert_ne!(zai.base_url, zai_coding.base_url);
3703
3704        let zhipu = ad_hoc_provider_defaults("zhipuai").expect("zhipu defaults");
3705        let zhipu_coding =
3706            ad_hoc_provider_defaults("zhipuai-coding-plan").expect("zhipu-coding defaults");
3707        assert_eq!(canonical_provider_id("zhipuai"), Some("zhipuai"));
3708        assert_eq!(
3709            canonical_provider_id("zhipuai-coding-plan"),
3710            Some("zhipuai-coding-plan")
3711        );
3712        assert_eq!(zhipu.api, "openai-completions");
3713        assert_eq!(zhipu_coding.api, "openai-completions");
3714        assert_ne!(zhipu.base_url, zhipu_coding.base_url);
3715    }
3716
3717    #[test]
3718    fn ad_hoc_batch_c1_defaults_resolve_expected_routes() {
3719        let cases = [
3720            ("baseten", "https://inference.baseten.co/v1"),
3721            ("llama", "https://api.llama.com/compat/v1"),
3722            ("lmstudio", "http://127.0.0.1:1234/v1"),
3723            ("ollama-cloud", "https://ollama.com/v1"),
3724        ];
3725        for (provider, expected_base_url) in &cases {
3726            let defaults = ad_hoc_provider_defaults(provider)
3727                .unwrap_or_else(|| unreachable!("expected defaults for '{provider}'"));
3728            assert_eq!(defaults.api, "openai-completions");
3729            assert!(defaults.auth_header);
3730            assert_eq!(defaults.base_url, *expected_base_url);
3731        }
3732    }
3733
3734    #[test]
3735    fn ad_hoc_kimi_alias_and_kimi_for_coding_remain_distinct() {
3736        assert_eq!(canonical_provider_id("kimi"), Some("moonshotai"));
3737        assert_eq!(
3738            canonical_provider_id("kimi-for-coding"),
3739            Some("kimi-for-coding")
3740        );
3741
3742        let kimi_alias = ad_hoc_provider_defaults("kimi").expect("kimi alias defaults");
3743        let kimi_for_coding =
3744            ad_hoc_provider_defaults("kimi-for-coding").expect("kimi-for-coding defaults");
3745        assert!(kimi_alias.base_url.contains("moonshot.ai"));
3746        assert!(kimi_for_coding.base_url.contains("api.kimi.com"));
3747        assert_ne!(kimi_alias.base_url, kimi_for_coding.base_url);
3748        assert_ne!(kimi_alias.api, kimi_for_coding.api);
3749    }
3750
3751    #[test]
3752    fn ad_hoc_alibaba_cn_is_distinct_from_alibaba_family_aliases() {
3753        let alibaba = ad_hoc_provider_defaults("alibaba").expect("alibaba defaults");
3754        let alibaba_cn = ad_hoc_provider_defaults("alibaba-cn").expect("alibaba-cn defaults");
3755        let alibaba_us = ad_hoc_provider_defaults("alibaba-us").expect("alibaba-us defaults");
3756        assert_eq!(canonical_provider_id("dashscope"), Some("alibaba"));
3757        assert_eq!(canonical_provider_id("alibaba-cn"), Some("alibaba-cn"));
3758        assert_eq!(canonical_provider_id("alibaba-us"), Some("alibaba-us"));
3759        assert_eq!(alibaba.api, "openai-completions");
3760        assert_eq!(alibaba_cn.api, "openai-completions");
3761        assert_eq!(alibaba_us.api, "openai-completions");
3762        assert_ne!(alibaba.base_url, alibaba_cn.base_url);
3763        assert_ne!(alibaba.base_url, alibaba_us.base_url);
3764        assert_ne!(alibaba_cn.base_url, alibaba_us.base_url);
3765    }
3766
3767    #[test]
3768    fn ad_hoc_moonshot_cn_is_distinct_from_global_moonshot_aliases() {
3769        let moonshot_global = ad_hoc_provider_defaults("moonshot").expect("moonshot defaults");
3770        let moonshot_cn =
3771            ad_hoc_provider_defaults("moonshotai-cn").expect("moonshotai-cn defaults");
3772        assert_eq!(canonical_provider_id("moonshot"), Some("moonshotai"));
3773        assert_eq!(
3774            canonical_provider_id("moonshotai-cn"),
3775            Some("moonshotai-cn")
3776        );
3777        assert_eq!(moonshot_global.api, "openai-completions");
3778        assert_eq!(moonshot_cn.api, "openai-completions");
3779        assert_ne!(moonshot_global.base_url, moonshot_cn.base_url);
3780    }
3781
3782    #[test]
3783    fn ad_hoc_unknown_returns_none() {
3784        assert!(ad_hoc_provider_defaults("unknown-provider").is_none());
3785        assert!(ad_hoc_provider_defaults("").is_none());
3786    }
3787
3788    #[test]
3789    fn ad_hoc_anthropic_uses_messages_api() {
3790        let defaults = ad_hoc_provider_defaults("anthropic").unwrap();
3791        assert_eq!(defaults.api, "anthropic-messages");
3792        assert_eq!(defaults.base_url, "https://api.anthropic.com/v1/messages");
3793        assert!(defaults.reasoning);
3794    }
3795
3796    #[test]
3797    fn ad_hoc_openai_uses_responses_api() {
3798        let defaults = ad_hoc_provider_defaults("openai").unwrap();
3799        assert_eq!(defaults.api, "openai-responses");
3800    }
3801
3802    #[test]
3803    fn ad_hoc_groq_uses_completions_api() {
3804        let defaults = ad_hoc_provider_defaults("groq").unwrap();
3805        assert_eq!(defaults.api, "openai-completions");
3806        assert!(defaults.base_url.contains("groq.com"));
3807    }
3808
3809    #[test]
3810    fn ad_hoc_bedrock_uses_converse_api() {
3811        let defaults = ad_hoc_provider_defaults("amazon-bedrock").unwrap();
3812        assert_eq!(defaults.api, "bedrock-converse-stream");
3813        assert_eq!(defaults.base_url, "");
3814        assert!(!defaults.auth_header);
3815    }
3816
3817    #[test]
3818    fn native_adapter_seed_defaults_gitlab_use_gitlab_chat_api() {
3819        let defaults = native_adapter_seed_defaults("gitlab").expect("gitlab seed defaults");
3820        assert_eq!(defaults.api, "gitlab-chat");
3821        assert_eq!(defaults.base_url, "");
3822        assert!(defaults.auth_header);
3823        assert!(defaults.reasoning);
3824        assert_eq!(defaults.input, &INPUT_TEXT_ONLY);
3825    }
3826
3827    // ─── ad_hoc_model_entry ──────────────────────────────────────────
3828
3829    #[test]
3830    fn ad_hoc_model_entry_creates_valid_entry() {
3831        // Use the pure SAP-resolver seam so the assertion stays hermetic and
3832        // independent of ambient `GROQ_API_KEY` / on-disk auth (the public
3833        // `ad_hoc_model_entry` intentionally resolves credentials).
3834        let entry = ad_hoc_model_entry_with_sap_resolver("groq", "llama-3-70b", || None).unwrap();
3835        assert_eq!(entry.model.id, "llama-3-70b");
3836        assert_eq!(entry.model.name, "llama-3-70b");
3837        assert_eq!(entry.model.provider, "groq");
3838        assert_eq!(entry.model.api, "openai-completions");
3839        assert!(entry.model.base_url.contains("groq.com"));
3840        assert!(entry.auth_header); // openai-compatible → auth_header = true
3841        assert!(entry.api_key.is_none()); // pure synthesis performs no auth lookup
3842    }
3843
3844    #[test]
3845    fn ad_hoc_model_entry_anthropic_no_auth_header() {
3846        let entry = ad_hoc_model_entry("anthropic", "claude-custom").unwrap();
3847        assert!(!entry.auth_header); // anthropic uses x-api-key, not Authorization
3848    }
3849
3850    #[test]
3851    fn ad_hoc_model_entry_unknown_returns_none() {
3852        assert!(ad_hoc_model_entry("nonexistent", "model").is_none());
3853    }
3854
3855    #[test]
3856    fn sap_chat_completions_endpoint_formats_expected_path() {
3857        let endpoint =
3858            sap_chat_completions_endpoint("https://api.ai.sap.example.com/", "deployment-a")
3859                .expect("endpoint");
3860        assert_eq!(
3861            endpoint,
3862            "https://api.ai.sap.example.com/v2/inference/deployments/deployment-a/chat/completions"
3863        );
3864    }
3865
3866    #[test]
3867    fn ad_hoc_model_entry_supports_sap_with_resolved_service_key() {
3868        let entry = ad_hoc_model_entry_with_sap_resolver("sap-ai-core", "dep-123", || {
3869            Some(SapResolvedCredentials {
3870                client_id: "id".to_string(),
3871                client_secret: "secret".to_string(),
3872                token_url: "https://auth.sap.example.com/oauth/token".to_string(),
3873                service_url: "https://api.ai.sap.example.com".to_string(),
3874            })
3875        })
3876        .expect("sap ad-hoc entry");
3877
3878        assert_eq!(entry.model.provider, "sap-ai-core");
3879        assert_eq!(entry.model.api, "openai-completions");
3880        assert_eq!(
3881            entry.model.base_url,
3882            "https://api.ai.sap.example.com/v2/inference/deployments/dep-123/chat/completions"
3883        );
3884        assert!(entry.auth_header);
3885    }
3886
3887    #[test]
3888    fn ad_hoc_model_entry_supports_sap_alias() {
3889        let entry = ad_hoc_model_entry_with_sap_resolver("sap", "dep-123", || {
3890            Some(SapResolvedCredentials {
3891                client_id: "id".to_string(),
3892                client_secret: "secret".to_string(),
3893                token_url: "https://auth.sap.example.com/oauth/token".to_string(),
3894                service_url: "https://api.ai.sap.example.com".to_string(),
3895            })
3896        })
3897        .expect("sap alias ad-hoc entry");
3898
3899        assert_eq!(entry.model.provider, "sap");
3900        assert_eq!(entry.model.api, "openai-completions");
3901        assert!(entry.auth_header);
3902    }
3903
3904    #[test]
3905    fn ad_hoc_model_entry_sap_without_credentials_returns_none() {
3906        assert!(ad_hoc_model_entry_with_sap_resolver("sap-ai-core", "dep-123", || None).is_none());
3907    }
3908
3909    #[test]
3910    fn ad_hoc_model_entry_sap_uses_effective_reasoning() {
3911        let sap_creds = || {
3912            Some(SapResolvedCredentials {
3913                client_id: "id".to_string(),
3914                client_secret: "secret".to_string(),
3915                token_url: "https://auth.sap.example.com/oauth/token".to_string(),
3916                service_url: "https://api.ai.sap.example.com".to_string(),
3917            })
3918        };
3919
3920        // A reasoning model (gpt-5.2) should have reasoning = true.
3921        let reasoning_entry =
3922            ad_hoc_model_entry_with_sap_resolver("sap-ai-core", "gpt-5.2", sap_creds)
3923                .expect("reasoning sap entry");
3924        assert!(reasoning_entry.model.reasoning);
3925
3926        // A non-reasoning model (gpt-4o) should have reasoning = false.
3927        let non_reasoning_entry =
3928            ad_hoc_model_entry_with_sap_resolver("sap-ai-core", "gpt-4o", sap_creds)
3929                .expect("non-reasoning sap entry");
3930        assert!(!non_reasoning_entry.model.reasoning);
3931    }
3932
3933    // ─── merge_headers ───────────────────────────────────────────────
3934
3935    #[test]
3936    fn merge_headers_combines_both() {
3937        let base = HashMap::from([
3938            ("a".to_string(), "1".to_string()),
3939            ("b".to_string(), "2".to_string()),
3940        ]);
3941        let overrides = HashMap::from([
3942            ("b".to_string(), "override".to_string()),
3943            ("c".to_string(), "3".to_string()),
3944        ]);
3945        let merged = merge_headers(&base, overrides);
3946        assert_eq!(merged.get("a").unwrap(), "1");
3947        assert_eq!(merged.get("b").unwrap(), "override");
3948        assert_eq!(merged.get("c").unwrap(), "3");
3949    }
3950
3951    #[test]
3952    fn merge_headers_empty_base() {
3953        let merged = merge_headers(
3954            &HashMap::new(),
3955            HashMap::from([("x".to_string(), "y".to_string())]),
3956        );
3957        assert_eq!(merged.len(), 1);
3958        assert_eq!(merged.get("x").unwrap(), "y");
3959    }
3960
3961    #[test]
3962    fn merge_headers_empty_overrides() {
3963        let base = HashMap::from([("x".to_string(), "y".to_string())]);
3964        let merged = merge_headers(&base, HashMap::new());
3965        assert_eq!(merged, base);
3966    }
3967
3968    // ─── resolve_value ───────────────────────────────────────────────
3969
3970    #[test]
3971    fn resolve_value_plain_literal() {
3972        assert_eq!(resolve_value("my-key").as_deref(), Some("my-key"));
3973    }
3974
3975    #[test]
3976    fn resolve_value_empty_returns_none() {
3977        assert!(resolve_value("").is_none());
3978    }
3979
3980    #[test]
3981    fn resolve_value_env_empty_var_name_returns_none() {
3982        assert!(resolve_value("env:").is_none());
3983    }
3984
3985    #[test]
3986    fn resolve_value_file_empty_path_returns_none() {
3987        assert!(resolve_value("file:").is_none());
3988    }
3989
3990    #[test]
3991    fn resolve_value_file_missing_returns_none() {
3992        assert!(resolve_value("file:/nonexistent/path/key.txt").is_none());
3993    }
3994
3995    #[test]
3996    fn resolve_value_file_relative_to_base_dir() {
3997        let dir = tempdir().expect("tempdir");
3998        let nested = dir.path().join("config");
3999        std::fs::create_dir_all(&nested).expect("create nested dir");
4000        let key_path = nested.join("relative-key.txt");
4001        std::fs::write(&key_path, "relative-value\n").expect("write relative key");
4002
4003        assert_eq!(
4004            resolve_value_with_base("file:relative-key.txt", Some(&nested)).as_deref(),
4005            Some("relative-value")
4006        );
4007    }
4008
4009    #[test]
4010    fn resolve_value_shell_echo() {
4011        let result = resolve_value("!echo hello");
4012        assert_eq!(result.as_deref(), Some("hello"));
4013    }
4014
4015    #[test]
4016    fn resolve_value_shell_failing_command() {
4017        assert!(resolve_value("!false").is_none());
4018    }
4019
4020    // ─── resolve_headers ─────────────────────────────────────────────
4021
4022    #[test]
4023    fn resolve_headers_none_returns_empty() {
4024        assert!(resolve_headers(None).is_empty());
4025    }
4026
4027    #[test]
4028    fn resolve_headers_resolves_literal_values() {
4029        let mut headers = HashMap::new();
4030        headers.insert("x-key".to_string(), "literal-value".to_string());
4031        let resolved = resolve_headers(Some(&headers));
4032        assert_eq!(resolved.get("x-key").unwrap(), "literal-value");
4033    }
4034
4035    // ─── ModelRegistry ───────────────────────────────────────────────
4036
4037    #[test]
4038    fn model_registry_get_available_returns_only_ready_models() {
4039        let (_dir, auth) = test_auth_storage();
4040        let registry = ModelRegistry::load(&auth, None);
4041        let available = registry.get_available();
4042        assert!(!available.is_empty());
4043        for entry in &available {
4044            assert!(
4045                model_entry_is_ready(entry),
4046                "all available models should be ready for use"
4047            );
4048        }
4049    }
4050
4051    #[test]
4052    fn model_registry_get_available_includes_keyless_models() {
4053        let dir = tempdir().expect("tempdir");
4054        let auth = AuthStorage::load(dir.path().join("auth.json")).expect("auth");
4055        let models_path = dir.path().join("models.json");
4056        let config = serde_json::json!({
4057            "providers": {
4058                "acme-local": {
4059                    "baseUrl": "http://127.0.0.1:11434/v1",
4060                    "api": "openai-completions",
4061                    "authHeader": false,
4062                    "models": [
4063                        { "id": "dev-model", "name": "Dev Model", "reasoning": false }
4064                    ]
4065                }
4066            }
4067        });
4068        std::fs::write(
4069            &models_path,
4070            serde_json::to_string(&config).expect("serialize models"),
4071        )
4072        .expect("write models.json");
4073
4074        let registry = ModelRegistry::load(&auth, Some(models_path));
4075        let available = registry.get_available();
4076        assert!(
4077            available
4078                .iter()
4079                .any(|entry| entry.model.provider == "acme-local" && entry.model.id == "dev-model"),
4080            "keyless models should be considered available"
4081        );
4082    }
4083
4084    #[test]
4085    fn model_registry_error_none_for_valid_load() {
4086        let (_dir, auth) = test_auth_storage();
4087        let registry = ModelRegistry::load(&auth, None);
4088        assert!(registry.error().is_none());
4089    }
4090
4091    #[test]
4092    fn model_registry_error_on_invalid_json() {
4093        let dir = tempdir().expect("tempdir");
4094        let auth = AuthStorage::load(dir.path().join("auth.json")).expect("auth");
4095        let models_path = dir.path().join("models.json");
4096        std::fs::write(&models_path, "not valid json").expect("write bad json");
4097        let registry = ModelRegistry::load(&auth, Some(models_path));
4098        assert!(registry.error().is_some());
4099    }
4100
4101    #[test]
4102    fn model_registry_load_missing_models_json_is_fine() {
4103        let dir = tempdir().expect("tempdir");
4104        let auth = AuthStorage::load(dir.path().join("auth.json")).expect("auth");
4105        let registry = ModelRegistry::load(&auth, Some(dir.path().join("nonexistent.json")));
4106        assert!(registry.error().is_none());
4107    }
4108
4109    // ─── default_models_path ─────────────────────────────────────────
4110
4111    #[test]
4112    fn default_models_path_joins_correctly() {
4113        let path = default_models_path(Path::new("/home/user/.pi"));
4114        assert_eq!(path, PathBuf::from("/home/user/.pi/models.json"));
4115    }
4116
4117    // ─── ModelsConfig deserialization ────────────────────────────────
4118
4119    #[test]
4120    fn models_config_deserialize_camel_case() {
4121        let json = r#"{
4122            "providers": {
4123                "acme": {
4124                    "baseUrl": "https://acme.com/v1",
4125                    "apiKey": "env:ACME_KEY",
4126                    "authHeader": true,
4127                    "models": [{
4128                        "id": "acme-1",
4129                        "contextWindow": 32000,
4130                        "maxTokens": 2048
4131                    }]
4132                }
4133            }
4134        }"#;
4135        let config: ModelsConfig = serde_json::from_str(json).expect("parse");
4136        let acme = config.providers.get("acme").expect("acme provider");
4137        assert_eq!(acme.base_url.as_deref(), Some("https://acme.com/v1"));
4138        assert_eq!(acme.auth_header, Some(true));
4139        let model = &acme.models.as_ref().unwrap()[0];
4140        assert_eq!(model.context_window, Some(32000));
4141        assert_eq!(model.max_tokens, Some(2048));
4142    }
4143
4144    #[test]
4145    fn models_config_empty_providers_ok() {
4146        let json = r#"{"providers": {}}"#;
4147        let config: ModelsConfig = serde_json::from_str(json).expect("parse");
4148        assert!(config.providers.is_empty());
4149    }
4150
4151    #[test]
4152    fn compat_config_deserialize() {
4153        let json = r#"{
4154            "supportsStore": true,
4155            "supportsDeveloperRole": false,
4156            "supportsReasoningEffort": true,
4157            "supportsUsageInStreaming": false,
4158            "maxTokensField": "max_completion_tokens"
4159        }"#;
4160        let compat: CompatConfig = serde_json::from_str(json).expect("parse");
4161        assert_eq!(compat.supports_store, Some(true));
4162        assert_eq!(compat.supports_developer_role, Some(false));
4163        assert_eq!(compat.supports_reasoning_effort, Some(true));
4164        assert_eq!(compat.supports_usage_in_streaming, Some(false));
4165        assert_eq!(
4166            compat.max_tokens_field.as_deref(),
4167            Some("max_completion_tokens")
4168        );
4169    }
4170
4171    #[test]
4172    fn compat_config_deserialize_all_fields() {
4173        let json = r#"{
4174            "supportsStore": true,
4175            "supportsDeveloperRole": true,
4176            "supportsReasoningEffort": false,
4177            "supportsUsageInStreaming": false,
4178            "supportsTools": false,
4179            "supportsStreaming": true,
4180            "supportsParallelToolCalls": false,
4181            "maxTokensField": "max_completion_tokens",
4182            "systemRoleName": "developer",
4183            "stopReasonField": "finish_reason",
4184            "customHeaders": {"X-Region": "us-east-1", "X-Tag": "override"},
4185            "openRouterRouting": {"order": ["fallback"]},
4186            "vercelGatewayRouting": {"priority": 1}
4187        }"#;
4188        let compat: CompatConfig = serde_json::from_str(json).expect("parse");
4189        assert_eq!(compat.supports_tools, Some(false));
4190        assert_eq!(compat.supports_streaming, Some(true));
4191        assert_eq!(compat.supports_parallel_tool_calls, Some(false));
4192        assert_eq!(compat.system_role_name.as_deref(), Some("developer"));
4193        assert_eq!(compat.stop_reason_field.as_deref(), Some("finish_reason"));
4194        let custom = compat.custom_headers.as_ref().expect("custom_headers");
4195        assert_eq!(
4196            custom.get("X-Region").map(String::as_str),
4197            Some("us-east-1")
4198        );
4199        assert_eq!(custom.get("X-Tag").map(String::as_str), Some("override"));
4200        assert!(compat.open_router_routing.is_some());
4201        assert!(compat.vercel_gateway_routing.is_some());
4202    }
4203
4204    #[test]
4205    fn compat_config_default_all_none() {
4206        let compat = CompatConfig::default();
4207        assert!(compat.supports_store.is_none());
4208        assert!(compat.supports_tools.is_none());
4209        assert!(compat.supports_streaming.is_none());
4210        assert!(compat.max_tokens_field.is_none());
4211        assert!(compat.system_role_name.is_none());
4212        assert!(compat.stop_reason_field.is_none());
4213        assert!(compat.custom_headers.is_none());
4214    }
4215
4216    #[test]
4217    fn compat_config_deserialize_empty_object() {
4218        let compat: CompatConfig = serde_json::from_str("{}").expect("parse");
4219        assert!(compat.supports_store.is_none());
4220        assert!(compat.supports_tools.is_none());
4221        assert!(compat.custom_headers.is_none());
4222    }
4223
4224    // ─── apply_custom_models: provider replaces built-ins ────────────
4225
4226    #[test]
4227    fn apply_custom_models_replaces_built_in_when_models_specified() {
4228        let (_dir, auth) = test_auth_storage();
4229        let mut models = built_in_models(&auth, ModelRegistryLoadMode::Full);
4230        let anthropic_before = models
4231            .iter()
4232            .filter(|m| m.model.provider == "anthropic")
4233            .count();
4234        assert!(anthropic_before > 0);
4235
4236        let config = ModelsConfig {
4237            providers: HashMap::from([(
4238                "anthropic".to_string(),
4239                ProviderConfig {
4240                    base_url: Some("https://proxy.example/v1".to_string()),
4241                    api: Some("anthropic-messages".to_string()),
4242                    models: Some(vec![ModelConfig {
4243                        id: "custom-claude".to_string(),
4244                        name: Some("Custom Claude".to_string()),
4245                        ..ModelConfig::default()
4246                    }]),
4247                    ..ProviderConfig::default()
4248                },
4249            )]),
4250        };
4251
4252        apply_custom_models(&auth, &mut models, &config, None);
4253
4254        // Built-in anthropic models should be replaced
4255        let anthropic_after: Vec<_> = models
4256            .iter()
4257            .filter(|m| m.model.provider == "anthropic")
4258            .collect();
4259        assert_eq!(anthropic_after.len(), 1);
4260        assert_eq!(anthropic_after[0].model.id, "custom-claude");
4261    }
4262
4263    #[test]
4264    fn apply_custom_models_alias_replaces_canonical_built_ins_when_models_specified() {
4265        let (_dir, auth) = test_auth_storage();
4266        let mut models = built_in_models(&auth, ModelRegistryLoadMode::Full);
4267        let google_before = models
4268            .iter()
4269            .filter(|m| m.model.provider == "google")
4270            .count();
4271        assert!(google_before > 0);
4272
4273        let config = ModelsConfig {
4274            providers: HashMap::from([(
4275                "gemini".to_string(),
4276                ProviderConfig {
4277                    models: Some(vec![ModelConfig {
4278                        id: "gemini-custom".to_string(),
4279                        name: Some("Gemini Custom".to_string()),
4280                        ..ModelConfig::default()
4281                    }]),
4282                    ..ProviderConfig::default()
4283                },
4284            )]),
4285        };
4286
4287        apply_custom_models(&auth, &mut models, &config, None);
4288
4289        assert!(
4290            !models.iter().any(|m| m.model.provider == "google"),
4291            "canonical google built-ins should be replaced when alias config provides explicit models"
4292        );
4293        let gemini_models: Vec<_> = models
4294            .iter()
4295            .filter(|m| m.model.provider == "gemini")
4296            .collect();
4297        assert_eq!(gemini_models.len(), 1);
4298        assert_eq!(gemini_models[0].model.id, "gemini-custom");
4299    }
4300
4301    #[test]
4302    fn apply_custom_models_alias_override_without_models_updates_canonical_provider_models() {
4303        let (_dir, auth) = test_auth_storage();
4304        let mut models = built_in_models(&auth, ModelRegistryLoadMode::Full);
4305        let google_before = models
4306            .iter()
4307            .filter(|m| m.model.provider == "google")
4308            .count();
4309        assert!(google_before > 0);
4310
4311        let config = ModelsConfig {
4312            providers: HashMap::from([(
4313                "gemini".to_string(),
4314                ProviderConfig {
4315                    base_url: Some("https://proxy.example/v1".to_string()),
4316                    api: Some("google-generative-ai".to_string()),
4317                    auth_header: Some(true),
4318                    ..ProviderConfig::default()
4319                },
4320            )]),
4321        };
4322
4323        apply_custom_models(&auth, &mut models, &config, None);
4324
4325        let google_after: Vec<_> = models
4326            .iter()
4327            .filter(|m| m.model.provider == "google")
4328            .collect();
4329        assert_eq!(google_after.len(), google_before);
4330        assert!(
4331            google_after
4332                .iter()
4333                .all(|m| m.model.base_url == "https://proxy.example/v1")
4334        );
4335        assert!(
4336            google_after
4337                .iter()
4338                .all(|m| m.model.api == "google-generative-ai")
4339        );
4340        assert!(google_after.iter().all(|m| m.auth_header));
4341    }
4342
4343    #[test]
4344    fn model_registry_find_canonical_provider_matches_alias_backed_custom_model() {
4345        let (_dir, auth) = test_auth_storage();
4346        let mut models = Vec::new();
4347        let config = ModelsConfig {
4348            providers: HashMap::from([(
4349                "gemini".to_string(),
4350                ProviderConfig {
4351                    models: Some(vec![ModelConfig {
4352                        id: "gemini-custom-find".to_string(),
4353                        ..ModelConfig::default()
4354                    }]),
4355                    ..ProviderConfig::default()
4356                },
4357            )]),
4358        };
4359
4360        apply_custom_models(&auth, &mut models, &config, None);
4361        let registry = ModelRegistry {
4362            models,
4363            error: None,
4364        };
4365
4366        assert!(
4367            registry.find("gemini", "gemini-custom-find").is_some(),
4368            "alias lookup should resolve"
4369        );
4370        assert!(
4371            registry.find("google", "gemini-custom-find").is_some(),
4372            "canonical provider lookup should also match alias-backed model"
4373        );
4374    }
4375
4376    // ─── OAuthConfig ─────────────────────────────────────────────────
4377
4378    #[test]
4379    fn oauth_config_fields() {
4380        let config = OAuthConfig {
4381            auth_url: "https://auth.example.com/authorize".to_string(),
4382            token_url: "https://auth.example.com/token".to_string(),
4383            client_id: "client-123".to_string(),
4384            scopes: vec!["read".to_string(), "write".to_string()],
4385            redirect_uri: Some("http://localhost:8080/callback".to_string()),
4386        };
4387        assert_eq!(config.client_id, "client-123");
4388        assert_eq!(config.scopes.len(), 2);
4389        assert!(config.redirect_uri.is_some());
4390    }
4391
4392    // ─── Built-in model properties ───────────────────────────────────
4393
4394    #[test]
4395    fn built_in_anthropic_models_use_correct_api() {
4396        let (_dir, auth) = test_auth_storage();
4397        let models = built_in_models(&auth, ModelRegistryLoadMode::Full);
4398        for m in models.iter().filter(|m| m.model.provider == "anthropic") {
4399            assert_eq!(m.model.api, "anthropic-messages");
4400            assert!(!m.auth_header, "anthropic uses x-api-key, not auth header");
4401            assert!(
4402                m.model.context_window >= 200_000,
4403                "anthropic model {} should expose a modern context window",
4404                m.model.id
4405            );
4406        }
4407    }
4408
4409    #[test]
4410    fn built_in_openai_models_use_auth_header() {
4411        let (_dir, auth) = test_auth_storage();
4412        let models = built_in_models(&auth, ModelRegistryLoadMode::Full);
4413        for m in models.iter().filter(|m| m.model.provider == "openai") {
4414            assert!(m.auth_header, "openai uses Authorization header");
4415            assert_eq!(m.model.api, "openai-responses");
4416        }
4417    }
4418
4419    #[test]
4420    fn built_in_google_models_no_auth_header() {
4421        let (_dir, auth) = test_auth_storage();
4422        let models = built_in_models(&auth, ModelRegistryLoadMode::Full);
4423        for m in models.iter().filter(|m| m.model.provider == "google") {
4424            assert!(!m.auth_header, "google uses api key in URL, not header");
4425            assert_eq!(m.model.api, "google-generative-ai");
4426        }
4427    }
4428
4429    #[test]
4430    fn built_in_reasoning_models_marked_correctly() {
4431        let (_dir, auth) = test_auth_storage();
4432        let models = built_in_models(&auth, ModelRegistryLoadMode::Full);
4433        // Legacy Haiku 3.5 should remain non-reasoning.
4434        for m in models
4435            .iter()
4436            .filter(|m| m.model.id.contains("3-5-haiku-20241022"))
4437        {
4438            assert!(!m.model.reasoning, "{} should be non-reasoning", m.model.id);
4439        }
4440        let anthropic_opus_sonnet = models
4441            .iter()
4442            .filter(|m| {
4443                m.model.provider == "anthropic"
4444                    && (m.model.id.contains("opus") || m.model.id.contains("sonnet"))
4445            })
4446            .collect::<Vec<_>>();
4447        assert!(
4448            !anthropic_opus_sonnet.is_empty(),
4449            "expected anthropic opus/sonnet models in built-ins"
4450        );
4451        assert!(
4452            anthropic_opus_sonnet.iter().any(|m| m.model.reasoning),
4453            "expected at least one reasoning anthropic opus/sonnet model"
4454        );
4455
4456        // Modern Opus/Sonnet 4 family should be reasoning-enabled.
4457        for m in anthropic_opus_sonnet
4458            .iter()
4459            .filter(|m| m.model.id.contains("opus-4") || m.model.id.contains("sonnet-4"))
4460        {
4461            assert!(m.model.reasoning, "{} should be reasoning", m.model.id);
4462        }
4463    }
4464
4465    #[test]
4466    fn model_is_reasoning_known_families() {
4467        // OpenAI
4468        assert_eq!(model_is_reasoning("o1-preview"), Some(true));
4469        assert_eq!(model_is_reasoning("o3-mini"), Some(true));
4470        assert_eq!(model_is_reasoning("o4-mini"), Some(true));
4471        assert_eq!(model_is_reasoning("gpt-5"), Some(true));
4472        assert_eq!(model_is_reasoning("gpt-4o"), Some(false));
4473        assert_eq!(model_is_reasoning("gpt-4-turbo"), Some(false));
4474        assert_eq!(model_is_reasoning("gpt-3.5-turbo"), Some(false));
4475
4476        // Anthropic
4477        assert_eq!(model_is_reasoning("claude-sonnet-4-20250514"), Some(true));
4478        assert_eq!(model_is_reasoning("claude-opus-4-20250514"), Some(true));
4479        assert_eq!(model_is_reasoning("claude-3-5-sonnet-20241022"), Some(true));
4480        assert_eq!(model_is_reasoning("claude-3-5-haiku-20241022"), Some(false));
4481        assert_eq!(model_is_reasoning("claude-3-haiku-20240307"), Some(false));
4482        assert_eq!(model_is_reasoning("claude-3-opus-20240229"), Some(false));
4483        assert_eq!(model_is_reasoning("claude-3-sonnet-20240229"), Some(false));
4484
4485        // Google
4486        assert_eq!(model_is_reasoning("gemini-2.5-pro"), Some(true));
4487        assert_eq!(model_is_reasoning("gemini-2.5-flash"), Some(true));
4488        assert_eq!(
4489            model_is_reasoning("gemini-2.0-flash-thinking-exp"),
4490            Some(true)
4491        );
4492        assert_eq!(model_is_reasoning("gemini-2.0-flash"), Some(false));
4493        assert_eq!(model_is_reasoning("gemini-2.0-flash-lite"), Some(false));
4494        assert_eq!(model_is_reasoning("gemini-1.5-pro"), Some(false));
4495
4496        // Cohere
4497        assert_eq!(model_is_reasoning("command-a-03-2025"), Some(true));
4498        assert_eq!(model_is_reasoning("command-r-plus"), Some(false));
4499        assert_eq!(model_is_reasoning("command-r"), Some(false));
4500
4501        // DeepSeek
4502        assert_eq!(model_is_reasoning("deepseek-reasoner"), Some(true));
4503        assert_eq!(model_is_reasoning("deepseek-r1"), Some(true));
4504        assert_eq!(model_is_reasoning("deepseek-chat"), Some(false));
4505        assert_eq!(model_is_reasoning("deepseek-coder"), Some(false));
4506
4507        // Qwen
4508        assert_eq!(model_is_reasoning("qwq-32b"), Some(true));
4509        assert_eq!(model_is_reasoning("qwq-1b"), Some(true));
4510
4511        // Mistral
4512        assert_eq!(model_is_reasoning("mistral-large-latest"), Some(false));
4513        assert_eq!(model_is_reasoning("mistral-small-latest"), Some(false));
4514        assert_eq!(model_is_reasoning("codestral-latest"), Some(false));
4515        assert_eq!(model_is_reasoning("pixtral-large-latest"), Some(false));
4516
4517        // Meta Llama
4518        assert_eq!(model_is_reasoning("llama-3.3-70b-versatile"), Some(false));
4519        assert_eq!(model_is_reasoning("llama-4-scout"), Some(false));
4520
4521        // Unknown models return None (fall back to provider default)
4522        assert_eq!(model_is_reasoning("some-custom-model"), None);
4523        assert_eq!(model_is_reasoning("my-fine-tune"), None);
4524    }
4525
4526    // -------- User model overrides (issue #60) --------
4527
4528    #[test]
4529    fn parse_user_model_overrides_at_returns_empty_for_missing_file() {
4530        let dir = tempdir().expect("tempdir");
4531        let missing = dir.path().join("nope.json");
4532        assert!(parse_user_model_overrides_at(&missing).is_empty());
4533    }
4534
4535    #[test]
4536    fn parse_user_model_overrides_at_returns_empty_for_blank_file() {
4537        let dir = tempdir().expect("tempdir");
4538        let path = dir.path().join("models-override.json");
4539        fs::write(&path, "   \n  \t").expect("write blank override");
4540        assert!(parse_user_model_overrides_at(&path).is_empty());
4541    }
4542
4543    #[test]
4544    fn parse_user_model_overrides_at_returns_empty_for_malformed_json() {
4545        // A malformed override file must not break startup — it should log
4546        // and return an empty map. (issue #60: "no surprises" requirement.)
4547        let dir = tempdir().expect("tempdir");
4548        let path = dir.path().join("models-override.json");
4549        fs::write(&path, "{ this is not json }").expect("write bad json");
4550        assert!(parse_user_model_overrides_at(&path).is_empty());
4551    }
4552
4553    #[test]
4554    fn parse_user_model_overrides_at_loads_well_formed_overrides() {
4555        let dir = tempdir().expect("tempdir");
4556        let path = dir.path().join("models-override.json");
4557        fs::write(
4558            &path,
4559            r#"{"anthropic": ["claude-opus-4-7"], "openrouter": ["anthropic/claude-opus-4-7"]}"#,
4560        )
4561        .expect("write override");
4562
4563        let overrides = parse_user_model_overrides_at(&path);
4564        assert_eq!(
4565            overrides.get("anthropic").map(Vec::as_slice),
4566            Some(&["claude-opus-4-7".to_string()][..])
4567        );
4568        assert_eq!(
4569            overrides.get("openrouter").map(Vec::as_slice),
4570            Some(&["anthropic/claude-opus-4-7".to_string()][..])
4571        );
4572    }
4573
4574    #[test]
4575    fn merge_provider_model_ids_unions_entries_per_provider() {
4576        // Set-union semantics from issue #60: if a model appears in both the
4577        // bundled snapshot and the user override, dedup keeps it once.
4578        let mut target: HashMap<String, Vec<String>> = HashMap::new();
4579        let mut snapshot = HashMap::new();
4580        snapshot.insert(
4581            "anthropic".to_string(),
4582            vec![
4583                "claude-opus-4-6".to_string(),
4584                "claude-haiku-4-5".to_string(),
4585            ],
4586        );
4587        merge_provider_model_ids(&mut target, snapshot);
4588
4589        let mut user = HashMap::new();
4590        user.insert(
4591            "anthropic".to_string(),
4592            vec!["claude-opus-4-6".to_string(), "claude-opus-4-7".to_string()],
4593        );
4594        merge_provider_model_ids(&mut target, user);
4595
4596        let mut anthropic = target.remove("anthropic").expect("anthropic key");
4597        anthropic.sort_unstable();
4598        anthropic.dedup();
4599        assert_eq!(
4600            anthropic,
4601            vec![
4602                "claude-haiku-4-5".to_string(),
4603                "claude-opus-4-6".to_string(),
4604                "claude-opus-4-7".to_string(),
4605            ]
4606        );
4607    }
4608
4609    #[test]
4610    fn merge_provider_model_ids_skips_blank_entries() {
4611        let mut target: HashMap<String, Vec<String>> = HashMap::new();
4612        let mut user = HashMap::new();
4613        user.insert(
4614            " ".to_string(), // blank provider
4615            vec!["foo".to_string()],
4616        );
4617        user.insert(
4618            "anthropic".to_string(),
4619            vec![
4620                String::new(),
4621                " ".to_string(),
4622                "claude-opus-4-7".to_string(),
4623            ],
4624        );
4625        merge_provider_model_ids(&mut target, user);
4626
4627        assert_eq!(
4628            target.get("anthropic").map_or(&[][..], Vec::as_slice),
4629            &["claude-opus-4-7".to_string()]
4630        );
4631        assert!(!target.contains_key(" "));
4632    }
4633
4634    #[test]
4635    fn user_model_overrides_fingerprint_at_changes_with_content() {
4636        let dir = tempdir().expect("tempdir");
4637        let path = dir.path().join("models-override.json");
4638
4639        // Missing file => 0
4640        assert_eq!(user_model_overrides_fingerprint_at(&path), 0);
4641
4642        fs::write(&path, r#"{"anthropic":["a"]}"#).expect("write v1");
4643        let fp_v1 = user_model_overrides_fingerprint_at(&path);
4644        assert_ne!(fp_v1, 0, "non-empty file should not hash to 0");
4645
4646        fs::write(&path, r#"{"anthropic":["b"]}"#).expect("write v2");
4647        let fp_v2 = user_model_overrides_fingerprint_at(&path);
4648        assert_ne!(fp_v1, fp_v2, "fingerprint must change when content changes");
4649    }
4650
4651    mod proptest_models {
4652        use super::*;
4653        use proptest::prelude::*;
4654
4655        fn dummy_model(id: &str, reasoning: bool) -> ModelEntry {
4656            ModelEntry {
4657                model: Model {
4658                    id: id.to_string(),
4659                    name: id.to_string(),
4660                    provider: "test".to_string(),
4661                    api: "messages".to_string(),
4662                    base_url: String::new(),
4663                    reasoning,
4664                    input: vec![InputType::Text],
4665                    context_window: 128_000,
4666                    max_tokens: 4096,
4667                    cost: ModelCost {
4668                        input: 0.0,
4669                        output: 0.0,
4670                        cache_read: 0.0,
4671                        cache_write: 0.0,
4672                    },
4673                    headers: HashMap::new(),
4674                },
4675                api_key: None,
4676                headers: HashMap::new(),
4677                auth_header: false,
4678                compat: None,
4679                oauth_config: None,
4680            }
4681        }
4682
4683        proptest! {
4684            /// Non-reasoning models always clamp to `Off`.
4685            #[test]
4686            fn clamp_thinking_non_reasoning(level_idx in 0..6usize) {
4687                use crate::model::ThinkingLevel;
4688                let levels = [
4689                    ThinkingLevel::Off,
4690                    ThinkingLevel::Minimal,
4691                    ThinkingLevel::Low,
4692                    ThinkingLevel::Medium,
4693                    ThinkingLevel::High,
4694                    ThinkingLevel::XHigh,
4695                ];
4696                let entry = dummy_model("non-reasoning-model", false);
4697                assert_eq!(entry.clamp_thinking_level(levels[level_idx]), ThinkingLevel::Off);
4698            }
4699
4700            /// Reasoning models without xhigh downgrade `XHigh` to `High`.
4701            #[test]
4702            fn clamp_thinking_reasoning_no_xhigh(level_idx in 0..6usize) {
4703                use crate::model::ThinkingLevel;
4704                let levels = [
4705                    ThinkingLevel::Off,
4706                    ThinkingLevel::Minimal,
4707                    ThinkingLevel::Low,
4708                    ThinkingLevel::Medium,
4709                    ThinkingLevel::High,
4710                    ThinkingLevel::XHigh,
4711                ];
4712                let entry = dummy_model("claude-sonnet-4-5", true);
4713                let result = entry.clamp_thinking_level(levels[level_idx]);
4714                if levels[level_idx] == ThinkingLevel::XHigh {
4715                    assert_eq!(result, ThinkingLevel::High);
4716                } else {
4717                    assert_eq!(result, levels[level_idx]);
4718                }
4719            }
4720
4721            /// `supports_xhigh` only returns true for specific model IDs.
4722            #[test]
4723            fn supports_xhigh_specific_ids(id in "[a-z\\-0-9]{5,20}") {
4724                let entry = dummy_model(&id, true);
4725                let expected = matches!(
4726                    id.as_str(),
4727                    "gpt-5.1-codex-max"
4728                        | "gpt-5.2"
4729                        | "gpt-5.4"
4730                        | "gpt-5.2-codex"
4731                        | "gpt-5.3-codex"
4732                        | "gpt-5.3-codex-spark"
4733                );
4734                assert_eq!(entry.supports_xhigh(), expected);
4735            }
4736
4737            /// `canonicalize_openrouter_model_id` maps known aliases.
4738            #[test]
4739            fn openrouter_known_aliases(idx in 0..5usize) {
4740                let pairs = [
4741                    ("auto", "openrouter/auto"),
4742                    ("gpt-4o-mini", "openai/gpt-4o-mini"),
4743                    ("gpt-4o", "openai/gpt-4o"),
4744                    ("claude-3.5-sonnet", "anthropic/claude-3.5-sonnet"),
4745                    ("gemini-2.5-pro", "google/gemini-2.5-pro"),
4746                ];
4747                let (input, expected) = pairs[idx];
4748                assert_eq!(canonicalize_openrouter_model_id(input), expected);
4749            }
4750
4751            /// `canonicalize_openrouter_model_id` is case-insensitive for aliases.
4752            #[test]
4753            fn openrouter_case_insensitive(idx in 0..5usize) {
4754                let aliases = ["auto", "gpt-4o-mini", "gpt-4o", "claude-3.5-sonnet", "gemini-2.5-pro"];
4755                let lower = canonicalize_openrouter_model_id(aliases[idx]);
4756                let upper = canonicalize_openrouter_model_id(&aliases[idx].to_uppercase());
4757                assert_eq!(lower, upper);
4758            }
4759
4760            /// `canonicalize_openrouter_model_id` passes unknown IDs through.
4761            #[test]
4762            fn openrouter_passthrough(id in "[a-z]/[a-z]{5,15}") {
4763                let result = canonicalize_openrouter_model_id(&id);
4764                assert_eq!(result, id);
4765            }
4766
4767            /// `openrouter_model_lookup_ids` always includes the canonical form.
4768            #[test]
4769            fn openrouter_lookup_includes_canonical(id in "[a-z\\-0-9]{1,20}") {
4770                let ids = openrouter_model_lookup_ids(&id);
4771                let canonical = canonicalize_openrouter_model_id(&id);
4772                assert!(ids.contains(&canonical));
4773            }
4774
4775            /// `merge_headers` override wins for duplicate keys.
4776            #[test]
4777            fn merge_headers_override_wins(key in "[a-z]{1,5}", v1 in "[a-z]{1,5}", v2 in "[a-z]{1,5}") {
4778                let base = HashMap::from([(key.clone(), v1)]);
4779                let over = HashMap::from([(key.clone(), v2.clone())]);
4780                let merged = merge_headers(&base, over);
4781                assert_eq!(merged.get(&key).unwrap(), &v2);
4782            }
4783
4784            /// `merge_headers` preserves non-overlapping keys.
4785            #[test]
4786            fn merge_headers_preserves_both(k1 in "[a-z]{1,5}", k2 in "[A-Z]{1,5}", v1 in "[a-z]{1,5}", v2 in "[a-z]{1,5}") {
4787                let base = HashMap::from([(k1.clone(), v1.clone())]);
4788                let over = HashMap::from([(k2.clone(), v2.clone())]);
4789                let merged = merge_headers(&base, over);
4790                assert_eq!(merged.get(&k1), Some(&v1));
4791                assert_eq!(merged.get(&k2), Some(&v2));
4792            }
4793
4794            /// `sap_chat_completions_endpoint` rejects empty inputs.
4795            #[test]
4796            fn sap_endpoint_rejects_empty(s in "[a-z]{0,10}") {
4797                assert_eq!(sap_chat_completions_endpoint("", &s), None);
4798                assert_eq!(sap_chat_completions_endpoint(&s, ""), None);
4799                assert_eq!(sap_chat_completions_endpoint("  ", &s), None);
4800            }
4801
4802            /// `sap_chat_completions_endpoint` formats correctly.
4803            #[test]
4804            fn sap_endpoint_format(base in "[a-z]{3,10}", deployment in "[a-z]{3,10}") {
4805                let url = format!("https://{base}.example.com");
4806                let result = sap_chat_completions_endpoint(&url, &deployment);
4807                assert!(result.is_some());
4808                let endpoint = result.unwrap();
4809                assert!(endpoint.contains(&deployment));
4810                assert!(endpoint.contains("/v2/inference/deployments/"));
4811                assert!(endpoint.ends_with("/chat/completions"));
4812            }
4813
4814            /// `sap_chat_completions_endpoint` strips trailing slashes.
4815            #[test]
4816            fn sap_endpoint_strips_trailing_slash(base in "[a-z]{5,10}") {
4817                let url_no_slash = format!("https://{base}");
4818                let url_slash = format!("https://{base}/");
4819                let r1 = sap_chat_completions_endpoint(&url_no_slash, "model");
4820                let r2 = sap_chat_completions_endpoint(&url_slash, "model");
4821                assert_eq!(r1, r2);
4822            }
4823        }
4824    }
4825}