1use 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 pub oauth_config: Option<OAuthConfig>,
26}
27
28impl ModelEntry {
29 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 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 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 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 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#[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 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 pub max_tokens_field: Option<String>,
203 pub system_role_name: Option<String>,
205 pub stop_reason_field: Option<String>,
207
208 pub custom_headers: Option<HashMap<String, String>>,
212
213 pub open_router_routing: Option<serde_json::Value>,
215 pub vercel_gateway_routing: Option<serde_json::Value>,
216
217 pub thinking_level_map: Option<HashMap<String, String>>,
224 pub force_adaptive_thinking: Option<bool>,
230 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 #[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
498fn 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
520fn 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
567fn 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 (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 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 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, ¤t_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 best = Some(entry);
814 }
815 }
816 best.cloned()
817 }
818
819 pub fn merge_entries(&mut self, entries: Vec<ModelEntry>) {
821 for entry in entries {
822 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
835fn 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
862fn 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 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 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 return Some(true);
913 }
914
915 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 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 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 if id.starts_with("qwq-") {
955 return Some(true);
956 }
957
958 if id.starts_with("mistral") || id.starts_with("codestral") || id.starts_with("pixtral") {
960 return Some(false);
961 }
962
963 if id.starts_with("llama") {
965 return Some(false);
966 }
967
968 None
971}
972
973fn 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
1080fn 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 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 }
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 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 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 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 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 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 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 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 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 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 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
2132fn 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 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
2211fn 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
2263pub fn default_models_path(agent_dir: &Path) -> PathBuf {
2265 agent_dir.join("models.json")
2266}
2267
2268#[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 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 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 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 #[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 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 assert_eq!(entry.model.base_url, "http://localhost:11434/v1/");
2984 assert_eq!(
2987 normalize_openai_base(&entry.model.base_url),
2988 "http://localhost:11434/v1/chat/completions"
2989 );
2990
2991 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 #[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 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 assert!(!looks_like_api_key_env_var("DASHSCOPE_API"));
3474 assert!(!looks_like_api_key_env_var("DASHSCOPE_TOKEN"));
3475 assert!(!looks_like_api_key_env_var("dashscope_api_key"));
3477 assert!(!looks_like_api_key_env_var("My_API_KEY"));
3478 assert!(!looks_like_api_key_env_var("sk-ant-api03-AAAA_API_KEY"));
3480 assert!(!looks_like_api_key_env_var("sk-1234567890"));
3481 assert!(!looks_like_api_key_env_var("_API_KEY"));
3483 assert!(!looks_like_api_key_env_var(""));
3484 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 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 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 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 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 #[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 #[test]
3797 fn supports_xhigh_true_for_deepseek_reasoning_models() {
3798 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 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 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 #[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 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 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 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 #[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 assert_eq!(model_is_reasoning("deepseek-v4-pro"), Some(true));
3954 assert_eq!(model_is_reasoning("deepseek-v4-flash"), Some(true));
3955 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 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 #[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 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 #[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 #[test]
4351 fn ad_hoc_model_entry_creates_valid_entry() {
4352 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); assert!(entry.api_key.is_none()); }
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); }
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 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 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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 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 #[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 #[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 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 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 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 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 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 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 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 assert_eq!(model_is_reasoning("qwq-32b"), Some(true));
5058 assert_eq!(model_is_reasoning("qwq-1b"), Some(true));
5059
5060 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 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 assert_eq!(model_is_reasoning("some-custom-model"), None);
5072 assert_eq!(model_is_reasoning("my-fine-tune"), None);
5073 }
5074
5075 #[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 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 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(), 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}