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