1use std::cell::RefCell;
23use std::collections::BTreeMap;
24use std::sync::OnceLock;
25
26use serde::Deserialize;
27
28use super::providers::anthropic::claude_generation;
29use super::providers::openai_compat::gpt_generation;
30
31const BUILTIN_TOML: &str = include_str!("capabilities.toml");
33
34#[derive(Debug, Clone, Deserialize, Default)]
37pub struct CapabilitiesFile {
38 #[serde(default)]
40 pub provider: BTreeMap<String, Vec<ProviderRule>>,
41 #[serde(default)]
44 pub provider_family: BTreeMap<String, String>,
45}
46
47#[derive(Debug, Clone, Deserialize)]
49pub struct ProviderRule {
50 pub model_match: String,
53 #[serde(default)]
58 pub version_min: Option<Vec<u32>>,
59 #[serde(default)]
60 pub native_tools: Option<bool>,
61 #[serde(default)]
62 pub defer_loading: Option<bool>,
63 #[serde(default)]
64 pub tool_search: Option<Vec<String>>,
65 #[serde(default)]
66 pub max_tools: Option<u32>,
67 #[serde(default)]
68 pub prompt_caching: Option<bool>,
69 #[serde(default)]
70 pub thinking: Option<bool>,
71 #[serde(default)]
78 pub preserve_thinking: Option<bool>,
79}
80
81#[derive(Debug, Clone, Default, PartialEq, Eq)]
85pub struct Capabilities {
86 pub native_tools: bool,
87 pub defer_loading: bool,
88 pub tool_search: Vec<String>,
89 pub max_tools: Option<u32>,
90 pub prompt_caching: bool,
91 pub thinking: bool,
92 pub preserve_thinking: bool,
93}
94
95thread_local! {
96 static USER_OVERRIDES: RefCell<Option<CapabilitiesFile>> = const { RefCell::new(None) };
101}
102
103static BUILTIN: OnceLock<CapabilitiesFile> = OnceLock::new();
107
108fn builtin() -> &'static CapabilitiesFile {
109 BUILTIN.get_or_init(|| {
110 toml::from_str::<CapabilitiesFile>(BUILTIN_TOML)
111 .expect("capabilities.toml must parse at build time")
112 })
113}
114
115pub fn set_user_overrides(file: Option<CapabilitiesFile>) {
119 USER_OVERRIDES.with(|cell| *cell.borrow_mut() = file);
120}
121
122pub fn clear_user_overrides() {
124 set_user_overrides(None);
125}
126
127pub fn set_user_overrides_toml(src: &str) -> Result<(), String> {
132 let parsed: CapabilitiesFile = toml::from_str(src).map_err(|e| e.to_string())?;
133 set_user_overrides(Some(parsed));
134 Ok(())
135}
136
137pub fn set_user_overrides_from_manifest_toml(src: &str) -> Result<(), String> {
149 #[derive(Deserialize)]
150 struct Manifest {
151 #[serde(default)]
152 capabilities: Option<CapabilitiesFile>,
153 }
154 let parsed: Manifest = toml::from_str(src).map_err(|e| e.to_string())?;
155 set_user_overrides(parsed.capabilities);
156 Ok(())
157}
158
159pub fn lookup(provider: &str, model: &str) -> Capabilities {
165 let user = USER_OVERRIDES.with(|cell| cell.borrow().clone());
166 lookup_with(provider, model, builtin(), user.as_ref())
167}
168
169fn lookup_with(
170 provider: &str,
171 model: &str,
172 builtin: &CapabilitiesFile,
173 user: Option<&CapabilitiesFile>,
174) -> Capabilities {
175 if provider == "mock" {
180 if let Some(caps) = try_match_layer(user, builtin, "anthropic", model, provider) {
181 return caps;
182 }
183 if let Some(caps) = try_match_layer(user, builtin, "openai", model, provider) {
184 return caps;
185 }
186 return Capabilities::default();
187 }
188
189 let mut current = provider.to_string();
192 let mut visited: std::collections::HashSet<String> = std::collections::HashSet::new();
193 while visited.insert(current.clone()) {
194 if let Some(caps) = try_match_layer(user, builtin, ¤t, model, provider) {
195 return caps;
196 }
197 let next = user
198 .and_then(|f| f.provider_family.get(¤t))
199 .or_else(|| builtin.provider_family.get(¤t))
200 .cloned();
201 match next {
202 Some(parent) => current = parent,
203 None => break,
204 }
205 }
206 Capabilities::default()
207}
208
209fn try_match_layer(
213 user: Option<&CapabilitiesFile>,
214 builtin: &CapabilitiesFile,
215 layer_provider: &str,
216 model: &str,
217 _original_provider: &str,
218) -> Option<Capabilities> {
219 if let Some(user) = user {
220 if let Some(rules) = user.provider.get(layer_provider) {
221 for rule in rules {
222 if rule_matches(rule, model) {
223 return Some(rule_to_caps(rule));
224 }
225 }
226 }
227 }
228 if let Some(rules) = builtin.provider.get(layer_provider) {
229 for rule in rules {
230 if rule_matches(rule, model) {
231 return Some(rule_to_caps(rule));
232 }
233 }
234 }
235 None
236}
237
238fn rule_to_caps(rule: &ProviderRule) -> Capabilities {
239 Capabilities {
240 native_tools: rule.native_tools.unwrap_or(false),
241 defer_loading: rule.defer_loading.unwrap_or(false),
242 tool_search: rule.tool_search.clone().unwrap_or_default(),
243 max_tools: rule.max_tools,
244 prompt_caching: rule.prompt_caching.unwrap_or(false),
245 thinking: rule.thinking.unwrap_or(false),
246 preserve_thinking: rule.preserve_thinking.unwrap_or(false),
247 }
248}
249
250fn rule_matches(rule: &ProviderRule, model: &str) -> bool {
251 let lower = model.to_lowercase();
252 if !glob_match(&rule.model_match.to_lowercase(), &lower) {
253 return false;
254 }
255 if let Some(version_min) = &rule.version_min {
256 if version_min.len() != 2 {
257 return false;
258 }
259 let want = (version_min[0], version_min[1]);
260 let have = match extract_version(model) {
261 Some(v) => v,
262 None => return false,
266 };
267 if have < want {
268 return false;
269 }
270 }
271 true
272}
273
274fn extract_version(model: &str) -> Option<(u32, u32)> {
279 claude_generation(model).or_else(|| gpt_generation(model))
280}
281
282fn glob_match(pattern: &str, input: &str) -> bool {
286 if let Some(prefix) = pattern.strip_suffix('*') {
287 if let Some(rest) = prefix.strip_prefix('*') {
288 return input.contains(rest);
290 }
291 return input.starts_with(prefix);
292 }
293 if let Some(suffix) = pattern.strip_prefix('*') {
294 return input.ends_with(suffix);
295 }
296 if pattern.contains('*') {
297 let parts: Vec<&str> = pattern.split('*').collect();
298 if parts.len() == 2 {
299 return input.starts_with(parts[0]) && input.ends_with(parts[1]);
300 }
301 return input == pattern;
302 }
303 input == pattern
304}
305
306#[cfg(test)]
307mod tests {
308 use super::*;
309
310 fn reset() {
311 clear_user_overrides();
312 }
313
314 #[test]
315 fn anthropic_opus_47_gets_full_capabilities() {
316 reset();
317 let caps = lookup("anthropic", "claude-opus-4-7");
318 assert!(caps.native_tools);
319 assert!(caps.defer_loading);
320 assert_eq!(caps.tool_search, vec!["bm25", "regex"]);
321 assert!(caps.prompt_caching);
322 assert!(caps.thinking);
323 assert_eq!(caps.max_tools, Some(10000));
324 }
325
326 #[test]
327 fn anthropic_haiku_44_has_no_tool_search() {
328 reset();
329 let caps = lookup("anthropic", "claude-haiku-4-4");
330 assert!(caps.native_tools);
332 assert!(caps.prompt_caching);
333 assert!(!caps.defer_loading);
334 assert!(caps.tool_search.is_empty());
335 }
336
337 #[test]
338 fn anthropic_haiku_45_supports_tool_search() {
339 reset();
340 let caps = lookup("anthropic", "claude-haiku-4-5");
341 assert!(caps.defer_loading);
342 assert_eq!(caps.tool_search, vec!["bm25", "regex"]);
343 }
344
345 #[test]
346 fn old_claude_gets_catchall() {
347 reset();
348 let caps = lookup("anthropic", "claude-opus-3-5");
349 assert!(caps.native_tools);
350 assert!(caps.prompt_caching);
351 assert!(!caps.defer_loading);
352 assert!(caps.tool_search.is_empty());
353 }
354
355 #[test]
356 fn openai_gpt_54_supports_tool_search() {
357 reset();
358 let caps = lookup("openai", "gpt-5.4");
359 assert!(caps.defer_loading);
360 assert_eq!(caps.tool_search, vec!["hosted", "client"]);
361 }
362
363 #[test]
364 fn openai_gpt_53_has_native_tools_only() {
365 reset();
366 let caps = lookup("openai", "gpt-5.3");
367 assert!(caps.native_tools);
368 assert!(!caps.defer_loading);
369 assert!(caps.tool_search.is_empty());
370 }
371
372 #[test]
373 fn openrouter_inherits_openai() {
374 reset();
375 let caps = lookup("openrouter", "gpt-5.4");
376 assert!(caps.defer_loading);
377 assert_eq!(caps.tool_search, vec!["hosted", "client"]);
378 }
379
380 #[test]
381 fn groq_inherits_openai_family_only() {
382 reset();
383 let caps = lookup("groq", "gpt-5.5-preview");
384 assert!(caps.defer_loading);
385 }
386
387 #[test]
388 fn mock_with_claude_model_routes_to_anthropic() {
389 reset();
390 let caps = lookup("mock", "claude-sonnet-4-7");
391 assert!(caps.defer_loading);
392 assert_eq!(caps.tool_search, vec!["bm25", "regex"]);
393 }
394
395 #[test]
396 fn mock_with_gpt_model_routes_to_openai() {
397 reset();
398 let caps = lookup("mock", "gpt-5.4-preview");
399 assert!(caps.defer_loading);
400 assert_eq!(caps.tool_search, vec!["hosted", "client"]);
401 }
402
403 #[test]
404 fn qwen36_ollama_preserves_thinking() {
405 reset();
406 let caps = lookup("ollama", "qwen3.6:35b-a3b-coding-nvfp4");
407 assert!(caps.native_tools);
408 assert!(caps.thinking);
409 assert!(
410 caps.preserve_thinking,
411 "Qwen3.6 should enable preserve_thinking by default for coding agents"
412 );
413 }
414
415 #[test]
416 fn qwen35_ollama_does_not_preserve_thinking() {
417 reset();
418 let caps = lookup("ollama", "qwen3.5:35b-a3b-coding-nvfp4");
419 assert!(caps.native_tools);
420 assert!(caps.thinking);
421 assert!(
422 !caps.preserve_thinking,
423 "Qwen3.5 lacks the preserve_thinking kwarg — rely on the chat template's rolling checkpoint instead"
424 );
425 }
426
427 #[test]
428 fn qwen36_routed_providers_all_preserve_thinking() {
429 reset();
430 for (provider, model) in [
431 ("openrouter", "qwen/qwen3.6-plus"),
432 ("together", "Qwen/Qwen3.6-35B-A3B"),
433 ("huggingface", "Qwen/Qwen3.6-35B-A3B"),
434 ("fireworks", "accounts/fireworks/models/qwen3p6-plus"),
435 ("dashscope", "qwen3.6-plus"),
436 ("llamacpp", "unsloth/Qwen3.6-35B-A3B-GGUF"),
437 ("local", "Qwen3.6-35B-A3B"),
438 ] {
439 let caps = lookup(provider, model);
440 assert!(caps.thinking, "{provider}/{model}: thinking");
441 assert!(
442 caps.preserve_thinking,
443 "{provider}/{model}: preserve_thinking must be on for Qwen3.6"
444 );
445 assert!(caps.native_tools, "{provider}/{model}: native_tools");
446 }
447 }
448
449 #[test]
450 fn dashscope_and_llamacpp_resolve_capabilities() {
451 reset();
452 let caps = lookup("dashscope", "gpt-5.4-preview");
455 assert!(caps.defer_loading);
456 let caps = lookup("llamacpp", "gpt-5.4-preview");
457 assert!(caps.defer_loading);
458 }
459
460 #[test]
461 fn unknown_provider_has_no_capabilities() {
462 reset();
463 let caps = lookup("my-custom-proxy", "foo-bar-1");
464 assert!(!caps.native_tools);
465 assert!(!caps.defer_loading);
466 assert!(caps.tool_search.is_empty());
467 }
468
469 #[test]
470 fn user_override_adds_new_provider() {
471 reset();
472 let toml_src = r#"
473[[provider.my-proxy]]
474model_match = "*"
475native_tools = true
476tool_search = ["hosted"]
477"#;
478 set_user_overrides_toml(toml_src).unwrap();
479 let caps = lookup("my-proxy", "anything");
480 assert!(caps.native_tools);
481 assert_eq!(caps.tool_search, vec!["hosted"]);
482 clear_user_overrides();
483 }
484
485 #[test]
486 fn user_override_takes_precedence_over_builtin() {
487 reset();
488 let toml_src = r#"
489[[provider.anthropic]]
490model_match = "claude-opus-*"
491native_tools = true
492defer_loading = false
493tool_search = []
494"#;
495 set_user_overrides_toml(toml_src).unwrap();
496 let caps = lookup("anthropic", "claude-opus-4-7");
497 assert!(caps.native_tools);
498 assert!(!caps.defer_loading);
499 assert!(caps.tool_search.is_empty());
500 clear_user_overrides();
501 }
502
503 #[test]
504 fn user_override_from_manifest_toml() {
505 reset();
506 let manifest = r#"
507[package]
508name = "demo"
509
510[[capabilities.provider.my-proxy]]
511model_match = "*"
512native_tools = true
513tool_search = ["hosted"]
514"#;
515 set_user_overrides_from_manifest_toml(manifest).unwrap();
516 let caps = lookup("my-proxy", "foo");
517 assert!(caps.native_tools);
518 assert_eq!(caps.tool_search, vec!["hosted"]);
519 clear_user_overrides();
520 }
521
522 #[test]
523 fn version_min_requires_parseable_model() {
524 reset();
525 let toml_src = r#"
526[[provider.custom]]
527model_match = "*"
528version_min = [5, 4]
529native_tools = true
530"#;
531 set_user_overrides_toml(toml_src).unwrap();
532 let caps = lookup("custom", "mystery-model");
534 assert!(!caps.native_tools);
535 clear_user_overrides();
536 }
537
538 #[test]
539 fn glob_match_substring() {
540 assert!(glob_match("*gpt*", "openai/gpt-5.4"));
541 assert!(glob_match("*claude*", "anthropic/claude-opus-4-7"));
542 assert!(!glob_match("*xyz*", "openai/gpt-5.4"));
543 }
544
545 #[test]
546 fn openrouter_namespaced_anthropic_model() {
547 reset();
548 let caps = lookup("anthropic", "anthropic/claude-opus-4-7");
549 assert!(caps.defer_loading);
550 }
551}