1use serde::{Deserialize, Serialize};
6
7use super::registry::{all_static_models, static_lookup};
8use super::types::{CapabilityFilter, DiscoveredModel};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
12pub enum ModelMatchKind {
13 ExactId,
15 ExactName,
17 ExactQualifiedId,
19 Prefix,
21 Contains,
23 Fuzzy,
25}
26
27#[derive(Debug, Clone)]
29pub struct ModelSearchMatch {
30 pub model: DiscoveredModel,
31 pub score: f64,
32 pub match_kind: ModelMatchKind,
33}
34
35#[derive(Debug, Clone, Default, Serialize, Deserialize)]
37pub struct ModelSearchQuery {
38 pub query: String,
40 pub provider: Option<String>,
42 pub fuzzy: bool,
44 pub min_score: Option<f64>,
46 pub limit: Option<usize>,
48 pub min_context_length: Option<usize>,
50 pub max_context_length: Option<usize>,
52 pub min_output_tokens: Option<usize>,
54 pub max_output_tokens: Option<usize>,
56 pub capability_filter: Option<CapabilityFilter>,
58}
59
60impl ModelSearchQuery {
61 pub fn new(query: impl Into<String>) -> Self {
62 Self {
63 query: query.into(),
64 ..Default::default()
65 }
66 }
67
68 pub fn fuzzy(mut self, enabled: bool) -> Self {
69 self.fuzzy = enabled;
70 self
71 }
72
73 pub fn with_provider(mut self, provider: impl Into<String>) -> Self {
74 self.provider = Some(provider.into());
75 self
76 }
77
78 pub fn with_min_score(mut self, score: f64) -> Self {
79 self.min_score = Some(score);
80 self
81 }
82
83 pub fn with_limit(mut self, limit: usize) -> Self {
84 self.limit = Some(limit);
85 self
86 }
87
88 pub fn with_capability_filter(mut self, filter: CapabilityFilter) -> Self {
89 self.capability_filter = Some(filter);
90 self
91 }
92
93 pub fn with_min_context_length(mut self, tokens: usize) -> Self {
94 self.min_context_length = Some(tokens);
95 self
96 }
97
98 pub fn with_max_context_length(mut self, tokens: usize) -> Self {
99 self.max_context_length = Some(tokens);
100 self
101 }
102
103 pub fn with_min_output_tokens(mut self, tokens: usize) -> Self {
104 self.min_output_tokens = Some(tokens);
105 self
106 }
107
108 pub fn with_max_output_tokens(mut self, tokens: usize) -> Self {
109 self.max_output_tokens = Some(tokens);
110 self
111 }
112
113 pub fn effective_filter(&self) -> CapabilityFilter {
115 let mut filter = self.capability_filter.clone().unwrap_or_default();
116 merge_min_usize(&mut filter.min_context_length, self.min_context_length);
117 merge_max_usize(&mut filter.max_context_length, self.max_context_length);
118 merge_min_usize(&mut filter.min_output_tokens, self.min_output_tokens);
119 merge_max_usize(&mut filter.max_output_tokens, self.max_output_tokens);
120 filter
121 }
122
123 fn effective_min_score(&self) -> f64 {
124 self.min_score
125 .unwrap_or(if self.fuzzy { 0.55 } else { 0.80 })
126 }
127
128 fn effective_limit(&self) -> usize {
129 self.limit.unwrap_or(20)
130 }
131}
132
133pub fn search_models(
135 models: impl IntoIterator<Item = DiscoveredModel>,
136 query: &ModelSearchQuery,
137) -> Vec<ModelSearchMatch> {
138 let (parsed_provider, parsed_model) = parse_qualified_query(&query.query);
139 let provider_filter = query.provider.as_deref().or(parsed_provider);
140 let search_text = parsed_model.unwrap_or(&query.query);
141
142 if search_text.trim().is_empty() {
143 return Vec::new();
144 }
145
146 let mut matches = Vec::new();
147 let qualified = parsed_model.is_some();
148 for model in models {
149 if let Some(provider) = provider_filter {
150 if !model.provider.eq_ignore_ascii_case(provider) {
151 continue;
152 }
153 }
154 let filter = query.effective_filter();
155 if !filter.matches(&model) {
156 continue;
157 }
158 if let Some((score, kind)) = score_model(search_text, &model, query.fuzzy, qualified) {
159 if score >= query.effective_min_score() {
160 matches.push(ModelSearchMatch {
161 model,
162 score,
163 match_kind: kind,
164 });
165 }
166 }
167 }
168
169 matches.sort_by(|a, b| {
170 b.score
171 .partial_cmp(&a.score)
172 .unwrap_or(std::cmp::Ordering::Equal)
173 .then_with(|| a.model.provider.cmp(&b.model.provider))
174 .then_with(|| a.model.id.cmp(&b.model.id))
175 });
176 matches.truncate(query.effective_limit());
177 matches
178}
179
180pub fn search_static_models(query: &ModelSearchQuery) -> Vec<ModelSearchMatch> {
182 search_models(all_static_models(), query)
183}
184
185pub fn static_lookup_by_name(provider: &str, name_or_id: &str) -> Option<DiscoveredModel> {
189 if let Some(model) = static_lookup(provider, name_or_id) {
190 return Some(model);
191 }
192
193 let models = match provider {
194 "openai" => super::registry::openai_models(),
195 "anthropic" => super::registry::anthropic_models(),
196 "gemini" => super::registry::gemini_models(),
197 "vertexai" => super::registry::vertexai_models(),
198 "mistral" => super::registry::mistral_models(),
199 "xai" => super::registry::xai_models(),
200 "cohere" => super::registry::cohere_models(),
201 "nvidia" => super::registry::nvidia_models(),
202 _ => return None,
203 };
204
205 models
206 .into_iter()
207 .find(|m| m.name.eq_ignore_ascii_case(name_or_id))
208}
209
210fn merge_min_usize(target: &mut Option<usize>, value: Option<usize>) {
211 if let Some(value) = value {
212 *target = Some(target.map(|current| current.max(value)).unwrap_or(value));
213 }
214}
215
216fn merge_max_usize(target: &mut Option<usize>, value: Option<usize>) {
217 if let Some(value) = value {
218 *target = Some(target.map(|current| current.min(value)).unwrap_or(value));
219 }
220}
221
222fn parse_qualified_query(query: &str) -> (Option<&str>, Option<&str>) {
223 let trimmed = query.trim();
224 if let Some((provider, model)) = trimmed.split_once('/') {
225 let provider = provider.trim();
226 let model = model.trim();
227 if !provider.is_empty() && !model.is_empty() {
228 return (Some(provider), Some(model));
229 }
230 }
231 (None, None)
232}
233
234fn normalize(value: &str) -> String {
235 value
236 .to_lowercase()
237 .chars()
238 .map(|c| if c.is_alphanumeric() { c } else { ' ' })
239 .collect::<String>()
240 .split_whitespace()
241 .collect::<Vec<_>>()
242 .join(" ")
243}
244
245fn compact(value: &str) -> String {
246 normalize(value)
247 .chars()
248 .filter(|c| !c.is_whitespace())
249 .collect()
250}
251
252fn score_model(
253 query: &str,
254 model: &DiscoveredModel,
255 fuzzy: bool,
256 qualified: bool,
257) -> Option<(f64, ModelMatchKind)> {
258 let q_norm = normalize(query);
259 let q_compact = compact(query);
260 if q_norm.is_empty() {
261 return None;
262 }
263
264 let id_norm = normalize(&model.id);
265 let name_norm = normalize(&model.name);
266 let id_compact = compact(&model.id);
267 let name_compact = compact(&model.name);
268 let qualified_id = format!("{} {}", model.provider, model.id);
269 let qualified_norm = normalize(&qualified_id);
270
271 if id_norm == q_norm || id_compact == q_compact {
272 return Some((1.0, ModelMatchKind::ExactId));
273 }
274 if name_norm == q_norm || name_compact == q_compact {
275 return Some((0.98, ModelMatchKind::ExactName));
276 }
277 if qualified_norm == q_norm {
278 return Some((0.96, ModelMatchKind::ExactQualifiedId));
279 }
280 if qualified {
281 return None;
282 }
283 if id_norm.starts_with(&q_norm)
284 || name_norm.starts_with(&q_norm)
285 || id_compact.starts_with(&q_compact)
286 {
287 return Some((0.92, ModelMatchKind::Prefix));
288 }
289 if id_norm.contains(&q_norm)
290 || name_norm.contains(&q_norm)
291 || id_compact.contains(&q_compact)
292 || name_compact.contains(&q_compact)
293 || model
294 .tags
295 .iter()
296 .any(|tag| normalize(tag).contains(&q_norm))
297 {
298 return Some((0.85, ModelMatchKind::Contains));
299 }
300
301 if !fuzzy {
302 return None;
303 }
304
305 let token_score = token_overlap_score(&q_norm, &id_norm, &name_norm, &model.tags);
306 let similarity = id_norm
307 .split_whitespace()
308 .chain(name_norm.split_whitespace())
309 .map(|part| dice_coefficient(&q_compact, &compact(part)))
310 .fold(0.0_f64, f64::max)
311 .max(dice_coefficient(&q_compact, &id_compact))
312 .max(dice_coefficient(&q_compact, &name_compact))
313 .max(token_score);
314
315 if similarity >= 0.55 {
316 Some((similarity, ModelMatchKind::Fuzzy))
317 } else {
318 None
319 }
320}
321
322fn token_overlap_score(query: &str, id: &str, name: &str, tags: &[String]) -> f64 {
323 let tokens: Vec<&str> = query.split_whitespace().collect();
324 if tokens.is_empty() {
325 return 0.0;
326 }
327 let haystacks = [id, name];
328 let matched = tokens
329 .iter()
330 .filter(|token| {
331 haystacks.iter().any(|h| h.contains(*token))
332 || tags.iter().any(|tag| normalize(tag).contains(*token))
333 })
334 .count();
335 (matched as f64 / tokens.len() as f64) * 0.72
336}
337
338fn dice_coefficient(a: &str, b: &str) -> f64 {
339 if a.is_empty() || b.is_empty() {
340 return 0.0;
341 }
342 if a == b {
343 return 1.0;
344 }
345
346 let a_bigrams = bigrams(a);
347 let b_bigrams = bigrams(b);
348 if a_bigrams.is_empty() || b_bigrams.is_empty() {
349 return if a.contains(b) || b.contains(a) {
350 0.65
351 } else {
352 0.0
353 };
354 }
355
356 let overlap = a_bigrams.iter().filter(|bg| b_bigrams.contains(bg)).count();
357 (2.0 * overlap as f64) / (a_bigrams.len() + b_bigrams.len()) as f64
358}
359
360fn bigrams(value: &str) -> Vec<(char, char)> {
361 let chars: Vec<char> = value.chars().collect();
362 if chars.len() < 2 {
363 return Vec::new();
364 }
365 chars.windows(2).map(|w| (w[0], w[1])).collect()
366}
367
368#[cfg(test)]
369mod tests {
370 use super::*;
371 use crate::model_config::ModelCapabilities;
372
373 fn sample(id: &str, name: &str, provider: &str) -> DiscoveredModel {
374 DiscoveredModel {
375 id: id.into(),
376 name: name.into(),
377 provider: provider.into(),
378 context_length: 128_000,
379 max_output_tokens: 4096,
380 capabilities: ModelCapabilities {
381 supports_function_calling: true,
382 ..Default::default()
383 },
384 ..Default::default()
385 }
386 }
387
388 #[test]
389 fn exact_id_match() {
390 let models = vec![sample("gpt-4.1", "GPT-4.1", "openai")];
391 let query = ModelSearchQuery::new("gpt-4.1");
392 let hits = search_models(models, &query);
393 assert_eq!(hits.len(), 1);
394 assert_eq!(hits[0].match_kind, ModelMatchKind::ExactId);
395 assert!((hits[0].score - 1.0).abs() < f64::EPSILON);
396 }
397
398 #[test]
399 fn qualified_query_parses_provider() {
400 let models = vec![
401 sample("gpt-4.1", "GPT-4.1", "openai"),
402 sample("gpt-4.1", "GPT-4.1", "azure"),
403 ];
404 let query = ModelSearchQuery::new("openai/gpt-4.1");
405 let hits = search_models(models, &query);
406 assert_eq!(hits.len(), 1);
407 assert_eq!(hits[0].model.provider, "openai");
408 }
409
410 #[test]
411 fn substring_search_finds_display_name() {
412 let models = vec![sample("claude-opus-4-8", "Claude Opus 4.8", "anthropic")];
413 let query = ModelSearchQuery::new("opus 4.8");
414 let hits = search_models(models, &query);
415 assert_eq!(hits.len(), 1);
416 assert_eq!(hits[0].match_kind, ModelMatchKind::Contains);
417 }
418
419 #[test]
420 fn fuzzy_search_handles_typos() {
421 let models = vec![sample("gpt-4.1", "GPT-4.1", "openai")];
422 let query = ModelSearchQuery::new("gpt-4l").fuzzy(true);
423 let hits = search_models(models, &query);
424 assert_eq!(hits.len(), 1);
425 assert_eq!(hits[0].match_kind, ModelMatchKind::Fuzzy);
426 }
427
428 #[test]
429 fn static_search_finds_known_model() {
430 let query = ModelSearchQuery::new("claude sonnet").fuzzy(true);
431 let hits = search_static_models(&query);
432 assert!(hits.iter().any(|m| m.model.id.contains("claude-sonnet")));
433 }
434
435 #[test]
436 fn search_respects_output_length_bounds() {
437 let mut model = sample("big-out", "Big Out", "openai");
438 model.max_output_tokens = 32_768;
439 let models = vec![model];
440 let query = ModelSearchQuery::new("big-out")
441 .with_min_output_tokens(16_384)
442 .with_max_output_tokens(65_536);
443 let hits = search_models(models, &query);
444 assert_eq!(hits.len(), 1);
445
446 let mut small = sample("big-out", "Big Out", "openai");
447 small.max_output_tokens = 4096;
448 let query = ModelSearchQuery::new("big-out").with_min_output_tokens(65_536);
449 assert!(search_models(vec![small], &query).is_empty());
450 }
451
452 #[test]
453 fn search_respects_input_length_bounds() {
454 let mut model = sample("big-ctx", "Big Context", "openai");
455 model.context_length = 1_000_000;
456 let models = vec![model];
457 let query = ModelSearchQuery::new("big")
458 .with_min_context_length(500_000)
459 .with_max_context_length(2_000_000);
460 assert_eq!(search_models(models.clone(), &query).len(), 1);
461
462 let mut small = sample("big-ctx", "Big Context", "openai");
463 small.context_length = 1_000_000;
464 let query = ModelSearchQuery::new("big").with_max_context_length(500_000);
465 assert!(search_models(vec![small], &query).is_empty());
466 }
467
468 #[test]
469 fn static_lookup_by_name_falls_back_to_display_name() {
470 let model = static_lookup_by_name("openai", "GPT-4.1");
471 assert!(model.is_some());
472 assert_eq!(model.unwrap().id, "gpt-4.1");
473 }
474}