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