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