返回 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 //! [`crate::model_registry`] 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::ApiProvider;
20 use crate::model_registry::{self, ModelMetadata};
21 use crate::tui::model_picker::format_picker_context_window;
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 = model_registry::lookup(model)?;
73 let badges = badges_from_registry(&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<ApiProvider> {
93 ApiProvider::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_picker_context_window(context)));
111 }
112 if let Some(output) = limits.output_tokens {
113 badges.push(format!("{} out", format_picker_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 seeded registry 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_registry(meta: &ModelMetadata) -> Vec<String> {
125 let mut badges = Vec::new();
126 if let Some(context) = meta.context_window {
127 badges.push(format!(
128 "{} ctx",
129 format_picker_context_window(u64::from(context))
130 ));
131 }
132 if let Some(output) = meta.max_output {
133 badges.push(format!(
134 "{} out",
135 format_picker_context_window(u64::from(output))
136 ));
137 }
138 if meta.supports_reasoning {
139 badges.push("reasoning".to_string());
140 }
141 badges
142 }
143
144 fn push_state_badge(badges: &mut Vec<String>, state: CapabilityState, name: &str) {
145 match state {
146 CapabilityState::Supported => badges.push(name.to_string()),
147 CapabilityState::Unsupported => badges.push(format!("no {name}")),
148 CapabilityState::Unknown => {}
149 }
150 }
151
152 #[cfg(test)]
153 mod tests {
154 use super::*;
155 use codewhale_config::catalog::CatalogOffering;
156 use codewhale_config::models_dev::{ModelsDevLimit, ModelsDevModalities};
157
158 #[test]
159 fn known_catalog_model_resolves_provider_aware_badges() {
160 let badges = resolve_route_capability_badges(Some("deepseek"), "deepseek-v4-pro")
161 .expect("bundled catalog knows deepseek-v4-pro");
162 assert!(
163 badges.badges.contains(&"1M ctx".to_string()),
164 "missing context badge: {:?}",
165 badges.badges
166 );
167 assert!(badges.badges.contains(&"384K out".to_string()));
168 assert!(badges.badges.contains(&"tools".to_string()));
169 assert!(badges.badges.contains(&"reasoning".to_string()));
170 // Text-only modalities are an explicit sourced fact, not an unknown.
171 assert!(badges.badges.contains(&"no vision".to_string()));
172 assert!(
173 badges.provenance.contains("catalog"),
174 "catalog facts must carry catalog provenance, got {}",
175 badges.provenance
176 );
177 assert!(badges.summary().contains(" · "));
178 }
179
180 #[test]
181 fn unknown_model_resolves_to_graceful_absence() {
182 assert_eq!(
183 resolve_route_capability_badges(Some("deepseek"), "totally-made-up-model-xyz"),
184 None
185 );
186 assert_eq!(resolve_route_capability_badges(None, ""), None);
187 assert_eq!(resolve_route_capability_badges(None, "auto"), None);
188 }
189
190 #[test]
191 fn registry_fallback_covers_routes_without_catalog_rows() {
192 // A named custom route key never inherits a built-in catalog; the
193 // provider-agnostic registry still answers for the model id.
194 let badges = resolve_route_capability_badges(Some("my-custom-endpoint"), "claude-fable-5")
195 .expect("registry knows claude-fable-5");
196 assert_eq!(badges.provenance, "registry");
197 assert!(badges.badges.contains(&"1M ctx".to_string()));
198 assert!(badges.badges.contains(&"reasoning".to_string()));
199 // Tool/vision facts are unknown here — omitted, never fabricated.
200 assert!(
201 !badges
202 .badges
203 .iter()
204 .any(|badge| badge.contains("tools") || badge.contains("vision")),
205 "unsourced facts must not appear: {:?}",
206 badges.badges
207 );
208 }
209
210 #[test]
211 fn auto_display_route_resolves_the_effective_model() {
212 let badges = resolve_route_capability_badges(None, "auto -> deepseek-v4-pro")
213 .expect("effective model resolves");
214 assert!(badges.badges.contains(&"1M ctx".to_string()));
215 }
216
217 #[test]
218 fn explicit_unsupported_catalog_facts_render_as_no_badges() {
219 let offering = CatalogOffering {
220 provider: "deepseek".to_string(),
221 wire_model_id: "fixture-model".to_string(),
222 endpoint_key: "chat".to_string(),
223 limit: Some(ModelsDevLimit {
224 context: Some(131_072),
225 input: None,
226 output: None,
227 }),
228 reasoning: Some(false),
229 tool_call: Some(false),
230 modalities: Some(ModelsDevModalities {
231 input: vec!["text".to_string(), "image".to_string()],
232 output: vec!["text".to_string()],
233 }),
234 ..CatalogOffering::default()
235 };
236 let route = offering.to_offering();
237 let badges = badges_from_route(&route.limits, &route.capabilities);
238 assert_eq!(
239 badges,
240 vec![
241 "131K ctx".to_string(),
242 "no tools".to_string(),
243 "no reasoning".to_string(),
244 "vision".to_string(),
245 ]
246 );
247 }
248
249 #[test]
250 fn token_labels_match_picker_vocabulary() {
251 assert_eq!(format_picker_context_window(1_000_000), "1M");
252 assert_eq!(format_picker_context_window(1_050_000), "1.05M");
253 assert_eq!(format_picker_context_window(262_144), "262K");
254 assert_eq!(format_picker_context_window(500), "500");
255 }
256 }
257
257 lines RUST