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