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