Skip to main content

pi/
models.rs

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