返回 CodeWhale
capability_badges.rs
根目录 / crates / tui / src / fleet / capability_badges.rs
1 //! Concise model-capability badges for Fleet configuration UI (#5038).
2 //!
3 //! One resolver answers "what can this model do" for both the Fleet setup
4 //! model-selection step and the roster detail pane. Facts come from the
5 //! existing owners — the merged Models.dev catalog (via
6 //! [`crate::provider_lake::catalog_offering_for_model`], provider-aware, with
7 //! bundled/live/override layers) first, then the seeded
8 //! reviewed intrinsic facts for ids no catalog row covers (custom
9 //! providers, local models). No second model catalog is introduced here.
10 //!
11 //! Honesty rules: unknown facts are omitted rather than guessed, an explicit
12 //! catalog "unsupported" renders as `no <badge>`, provenance is always named,
13 //! and a completely unknown model resolves to `None` so callers can skip the
14 //! line entirely instead of blocking selection or fabricating capabilities.
15
16 use codewhale_config::catalog::CatalogSource;
17 use codewhale_config::route::{CapabilityState, RouteCapabilities, RouteLimits};
18
19 use crate::config::ProviderKind;
20 use crate::utils::format_context_window;
21 use codewhale_config::catalog::reviewed::IntrinsicModel;
22
23 /// Resolved capability badges for one Fleet route.
24 #[derive(Debug, Clone, PartialEq, Eq)]
25 pub struct RouteCapabilityBadges {
26 /// Ordered, concise badges, e.g. `["1M ctx", "384K out", "tools",
27 /// "reasoning", "no vision"]`. Never empty when resolution succeeds.
28 pub badges: Vec<String>,
29 /// Where the facts came from: `bundled catalog`, `live catalog`,
30 /// `override`, or `registry`.
31 pub provenance: &'static str,
32 }
33
34 impl RouteCapabilityBadges {
35 /// One-line summary with provenance, sized for narrow detail panes:
36 /// `1M ctx · 384K out · tools · reasoning · no vision (bundled catalog)`.
37 #[must_use]
38 pub fn summary(&self) -> String {
39 format!("{} ({})", self.badges.join(" · "), self.provenance)
40 }
41 }
42
43 /// Resolve capability badges for one `(provider, model)` Fleet route.
44 ///
45 /// `provider_id` is the exact configured route key when known (canonical
46 /// built-in id or named custom table key); pass `None` when the route has no
47 /// resolvable provider (e.g. a pinned model that inherits the provider). Only
48 /// exact built-in ids reach the provider-scoped catalog; every other id falls
49 /// back to provider-agnostic registry facts rather than guessing a route.
50 ///
51 /// The session `auto -> model` display form is accepted and resolved against
52 /// the effective model. Returns `None` when nothing is known about the model,
53 /// so absence renders as absence.
54 #[must_use]
55 pub fn resolve_route_capability_badges(
56 provider_id: Option<&str>,
57 model: &str,
58 ) -> Option<RouteCapabilityBadges> {
59 let model = effective_model(model)?;
60 if let Some(provider) = provider_id.and_then(exact_builtin_provider)
61 && let Some(offering) = crate::provider_lake::catalog_offering_for_model(provider, model)
62 {
63 let route = offering.to_offering();
64 let badges = badges_from_route(&route.limits, &route.capabilities);
65 if !badges.is_empty() {
66 return Some(RouteCapabilityBadges {
67 badges,
68 provenance: catalog_provenance(&offering.source),
69 });
70 }
71 }
72 let meta = codewhale_config::catalog::reviewed::intrinsic_model(model)?;
73 let badges = badges_from_intrinsic(&meta);
74 (!badges.is_empty()).then_some(RouteCapabilityBadges {
75 badges,
76 provenance: "registry",
77 })
78 }
79
80 /// Strip the session `auto -> model` display form down to the effective model.
81 /// A bare `auto` (nothing resolved yet) or empty id yields `None`.
82 fn effective_model(model: &str) -> Option<&str> {
83 let model = model
84 .split_once("->")
85 .map_or(model, |(_, effective)| effective)
86 .trim();
87 (!model.is_empty() && !model.eq_ignore_ascii_case("auto")).then_some(model)
88 }
89
90 /// Accept only exact canonical built-in provider ids. Display labels and named
91 /// custom table keys must not inherit a built-in catalog by similarity.
92 fn exact_builtin_provider(provider_id: &str) -> Option<ProviderKind> {
93 ProviderKind::parse(provider_id).filter(|provider| provider.as_str() == provider_id)
94 }
95
96 const fn catalog_provenance(source: &CatalogSource) -> &'static str {
97 match source {
98 CatalogSource::Bundled | CatalogSource::CodewhaleBundled { .. } => "bundled catalog",
99 CatalogSource::Live { .. } | CatalogSource::ModelsDevLive { .. } => "live catalog",
100 CatalogSource::CloudFacts { .. } => "signed cloud facts",
101 CatalogSource::ConfigOverride | CatalogSource::UserOverride => "override",
102 }
103 }
104
105 /// Badges from exact provider-offering facts. Three-state facts keep their
106 /// explicit `Unsupported` (`no tools` / `no vision`); `Unknown` is omitted.
107 fn badges_from_route(limits: &RouteLimits, capabilities: &RouteCapabilities) -> Vec<String> {
108 let mut badges = Vec::new();
109 if let Some(context) = limits.context_tokens {
110 badges.push(format!("{} ctx", format_context_window(context)));
111 }
112 if let Some(output) = limits.output_tokens {
113 badges.push(format!("{} out", format_context_window(output)));
114 }
115 push_state_badge(&mut badges, capabilities.native_tool_calls, "tools");
116 push_state_badge(&mut badges, capabilities.reasoning, "reasoning");
117 push_state_badge(&mut badges, capabilities.image_input, "vision");
118 badges
119 }
120
121 /// Badges from reviewed intrinsic facts. The registry has no tool/vision facts,
122 /// and its `supports_reasoning: false` is a heuristic default rather than a
123 /// sourced denial, so only a positive reasoning fact is shown.
124 fn badges_from_intrinsic(meta: &IntrinsicModel) -> Vec<String> {
125 let mut badges = Vec::new();
126 if let Some(context) = meta.context_window {
127 badges.push(format!("{} ctx", format_context_window(u64::from(context))));
128 }
129 if let Some(output) = meta.generation_default.or(meta.max_output) {
130 badges.push(format!("{} out", format_context_window(u64::from(output))));
131 }
132 if meta.reasoning == Some(true) {
133 badges.push("reasoning".to_string());
134 }
135 badges
136 }
137
138 fn push_state_badge(badges: &mut Vec<String>, state: CapabilityState, name: &str) {
139 match state {
140 CapabilityState::Supported => badges.push(name.to_string()),
141 CapabilityState::Unsupported => badges.push(format!("no {name}")),
142 CapabilityState::Unknown => {}
143 }
144 }
145
146 #[cfg(test)]
147 mod tests {
148 use super::*;
149 use codewhale_config::catalog::CatalogOffering;
150 use codewhale_config::models_dev::{ModelsDevLimit, ModelsDevModalities};
151
152 #[test]
153 fn known_catalog_model_resolves_provider_aware_badges() {
154 let badges = resolve_route_capability_badges(Some("deepseek"), "deepseek-v4-pro")
155 .expect("bundled catalog knows deepseek-v4-pro");
156 assert!(
157 badges.badges.contains(&"1M ctx".to_string()),
158 "missing context badge: {:?}",
159 badges.badges
160 );
161 assert!(badges.badges.contains(&"384K out".to_string()));
162 assert!(badges.badges.contains(&"tools".to_string()));
163 assert!(badges.badges.contains(&"reasoning".to_string()));
164 // The offline seed's text-only row is not a refusal (#6396): the seed
165 // lags providers, so it may say "vision" but never "no vision".
166 assert!(!badges.badges.contains(&"no vision".to_string()));
167 assert!(
168 badges.provenance.contains("catalog"),
169 "catalog facts must carry catalog provenance, got {}",
170 badges.provenance
171 );
172 assert!(badges.summary().contains(" · "));
173 }
174
175 #[test]
176 fn unknown_model_resolves_to_graceful_absence() {
177 assert_eq!(
178 resolve_route_capability_badges(Some("deepseek"), "totally-made-up-model-xyz"),
179 None
180 );
181 assert_eq!(resolve_route_capability_badges(None, ""), None);
182 assert_eq!(resolve_route_capability_badges(None, "auto"), None);
183 }
184
185 #[test]
186 fn registry_fallback_covers_routes_without_catalog_rows() {
187 // A named custom route key never inherits a built-in catalog; the
188 // provider-agnostic registry still answers for the model id.
189 let badges = resolve_route_capability_badges(Some("my-custom-endpoint"), "claude-fable-5")
190 .expect("registry knows claude-fable-5");
191 assert_eq!(badges.provenance, "registry");
192 assert!(badges.badges.contains(&"1M ctx".to_string()));
193 assert!(badges.badges.contains(&"reasoning".to_string()));
194 // Tool/vision facts are unknown here — omitted, never fabricated.
195 assert!(
196 !badges
197 .badges
198 .iter()
199 .any(|badge| badge.contains("tools") || badge.contains("vision")),
200 "unsourced facts must not appear: {:?}",
201 badges.badges
202 );
203 }
204
205 #[test]
206 fn auto_display_route_resolves_the_effective_model() {
207 let badges = resolve_route_capability_badges(None, "auto -> deepseek-v4-pro")
208 .expect("effective model resolves");
209 assert!(badges.badges.contains(&"1M ctx".to_string()));
210 }
211
212 #[test]
213 fn explicit_unsupported_catalog_facts_render_as_no_badges() {
214 let offering = CatalogOffering {
215 provider: "deepseek".to_string(),
216 wire_model_id: "fixture-model".to_string(),
217 endpoint_key: "chat".to_string(),
218 limit: Some(ModelsDevLimit {
219 context: Some(131_072),
220 input: None,
221 output: None,
222 }),
223 reasoning: Some(false),
224 tool_call: Some(false),
225 modalities: Some(ModelsDevModalities {
226 input: vec!["text".to_string(), "image".to_string()],
227 output: vec!["text".to_string()],
228 }),
229 ..CatalogOffering::default()
230 };
231 let route = offering.to_offering();
232 let badges = badges_from_route(&route.limits, &route.capabilities);
233 assert_eq!(
234 badges,
235 vec![
236 "131K ctx".to_string(),
237 "no tools".to_string(),
238 "no reasoning".to_string(),
239 "vision".to_string(),
240 ]
241 );
242 }
243
244 #[test]
245 fn token_labels_match_picker_vocabulary() {
246 assert_eq!(format_context_window(1_000_000), "1M");
247 assert_eq!(format_context_window(1_050_000), "1.05M");
248 assert_eq!(format_context_window(262_144), "262K");
249 assert_eq!(format_context_window(500), "500");
250 }
251 }
252
252 lines RUST