返回 CodeWhale
model_profile.rs
根目录 / crates / tui / src / model_profile.rs
1 //! Typed model and resolved-route capability descriptors (#3365).
2 //!
3 //! This module bridges the additive config catalog intrinsic facts and the
4 //! provider+model capability matrix in [`crate::config::provider_capability`].
5 //! It intentionally keeps intrinsic model facts separate from resolved route
6 //! facts so future route resolution can combine catalog offerings, user
7 //! overrides, live hints, and auth readiness without scattering provider/model
8 //! string checks through prompt, tool, and Fleet code.
9 #![allow(dead_code)]
10
11 use crate::config::{ProviderKind, RequestPayloadMode, provider_capability};
12 pub use codewhale_config::catalog::reviewed::ModelFamily as ModelProvider;
13 use codewhale_config::route::{RouteCapabilities, RouteLimits};
14
15 /// Compatibility name for the canonical config-layer three-state fact.
16 pub use codewhale_config::route::CapabilityState as SupportState;
17
18 /// Coarse tool-catalog budget for the selected route.
19 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
20 pub enum ToolSurfaceBudget {
21 /// Keep only the most essential turn-one tool surface eager.
22 Compact,
23 /// Current default surface: core tools eager, long tail deferred.
24 Standard,
25 /// Large-window/full-capability routes can afford the standard full head.
26 Full,
27 }
28
29 /// Fact provenance for diagnostics and route explanations.
30 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
31 pub enum FactProvenance {
32 ResolvedRouteCandidate,
33 SeededModelRegistry,
34 LegacyModelHeuristics,
35 ConservativeUnknownFallback,
36 LegacyProviderFallback,
37 UserOverride,
38 }
39
40 /// Provider-agnostic facts owned by the model identity.
41 #[derive(Debug, Clone, PartialEq, Eq)]
42 pub struct IntrinsicCapabilityProfile {
43 pub context_window: Option<u32>,
44 pub max_output: Option<u32>,
45 pub reasoning: SupportState,
46 pub native_tool_calls: SupportState,
47 pub parallel_tool_calls: SupportState,
48 pub structured_output: SupportState,
49 pub streaming: SupportState,
50 pub prompt_caching: SupportState,
51 pub image_input: SupportState,
52 pub tool_surface_budget: ToolSurfaceBudget,
53 }
54
55 /// A model-owned profile. This does not imply a provider route is ready.
56 #[derive(Debug, Clone, PartialEq, Eq)]
57 pub struct ModelProfile {
58 pub canonical_id: String,
59 pub display_name: String,
60 pub aliases: Vec<String>,
61 pub family: Option<ModelProvider>,
62 pub capabilities: IntrinsicCapabilityProfile,
63 pub provenance: FactProvenance,
64 }
65
66 /// Optional capability overrides layered after provider capability facts.
67 #[derive(Debug, Clone, Default, PartialEq, Eq)]
68 pub struct CapabilityOverride {
69 pub context_window: Option<u32>,
70 pub max_output: Option<u32>,
71 pub reasoning: Option<SupportState>,
72 pub native_tool_calls: Option<SupportState>,
73 pub parallel_tool_calls: Option<SupportState>,
74 pub structured_output: Option<SupportState>,
75 pub streaming: Option<SupportState>,
76 pub prompt_caching: Option<SupportState>,
77 pub image_input: Option<SupportState>,
78 pub tool_surface_budget: Option<ToolSurfaceBudget>,
79 }
80
81 /// Capabilities after provider route facts and user overrides are applied.
82 #[derive(Debug, Clone, PartialEq, Eq)]
83 pub struct CapabilityProfile {
84 pub provider: ProviderKind,
85 pub canonical_model: Option<String>,
86 pub wire_model_id: String,
87 pub request_payload_mode: RequestPayloadMode,
88 pub context_window: Option<u32>,
89 pub max_output: Option<u32>,
90 pub reasoning: SupportState,
91 pub native_tool_calls: SupportState,
92 pub parallel_tool_calls: SupportState,
93 pub structured_output: SupportState,
94 pub streaming: SupportState,
95 pub prompt_caching: SupportState,
96 pub image_input: SupportState,
97 pub tool_surface_budget: ToolSurfaceBudget,
98 pub provenance: Vec<FactProvenance>,
99 }
100
101 impl CapabilityProfile {
102 #[must_use]
103 pub fn supports_reasoning(&self) -> bool {
104 self.reasoning.is_supported()
105 }
106
107 #[must_use]
108 pub fn supports_image_input(&self) -> bool {
109 self.image_input.is_supported()
110 }
111
112 #[must_use]
113 pub fn has_large_context(&self) -> bool {
114 self.context_window.is_some_and(|window| window >= 400_000)
115 }
116
117 #[must_use]
118 pub fn prefers_full_tool_surface(&self) -> bool {
119 matches!(self.tool_surface_budget, ToolSurfaceBudget::Full)
120 }
121
122 #[must_use]
123 pub fn suitable_for_broad_fleet_worker(&self) -> bool {
124 self.has_large_context()
125 && !matches!(self.native_tool_calls, SupportState::Unsupported)
126 && matches!(
127 self.tool_surface_budget,
128 ToolSurfaceBudget::Standard | ToolSurfaceBudget::Full
129 )
130 }
131 }
132
133 /// Build an intrinsic profile for any model string.
134 #[must_use]
135 pub fn model_profile(model: &str) -> ModelProfile {
136 let trimmed = model.trim();
137 let display_name = display_name(trimmed);
138 match codewhale_config::catalog::reviewed::intrinsic_model(trimmed) {
139 Some(meta) => {
140 let canonical_id = meta
141 .canonical_id
142 .clone()
143 .unwrap_or_else(|| trimmed.to_string());
144 let provenance = FactProvenance::SeededModelRegistry;
145 ModelProfile {
146 canonical_id,
147 display_name: meta.display_name.clone().unwrap_or(display_name),
148 aliases: meta.aliases,
149 family: meta.family,
150 capabilities: IntrinsicCapabilityProfile {
151 context_window: meta.context_window,
152 max_output: meta.generation_default.or(meta.max_output),
153 reasoning: SupportState::from_optional_bool(meta.reasoning),
154 native_tool_calls: meta.capabilities.native_tool_calls,
155 parallel_tool_calls: meta.capabilities.parallel_tool_calls,
156 structured_output: meta.capabilities.structured_output,
157 streaming: meta.capabilities.streaming,
158 prompt_caching: meta.capabilities.prompt_caching,
159 image_input: meta.capabilities.image_input,
160 tool_surface_budget: tool_surface_for_window(meta.context_window),
161 },
162 provenance,
163 }
164 }
165 None => ModelProfile {
166 canonical_id: trimmed.to_string(),
167 display_name,
168 aliases: Vec::new(),
169 family: None,
170 capabilities: IntrinsicCapabilityProfile {
171 context_window: None,
172 max_output: None,
173 reasoning: SupportState::Unknown,
174 native_tool_calls: SupportState::Unknown,
175 parallel_tool_calls: SupportState::Unknown,
176 structured_output: SupportState::Unknown,
177 streaming: SupportState::Unknown,
178 prompt_caching: SupportState::Unknown,
179 image_input: SupportState::Unknown,
180 tool_surface_budget: ToolSurfaceBudget::Compact,
181 },
182 provenance: FactProvenance::ConservativeUnknownFallback,
183 },
184 }
185 }
186
187 /// Resolve a profile from legacy model/provider heuristics.
188 ///
189 /// This remains a picker/startup fallback for call sites that do not yet hold
190 /// an executable route candidate. Runtime execution should use
191 /// [`resolved_capability_profile_for_route`], where exact offering facts win.
192 #[must_use]
193 pub fn resolved_capability_profile(
194 provider: ProviderKind,
195 wire_model_id: &str,
196 ) -> CapabilityProfile {
197 resolved_capability_profile_with_overrides(
198 provider,
199 wire_model_id,
200 CapabilityOverride::default(),
201 )
202 }
203
204 /// Resolve legacy fallback capabilities and apply explicit overrides last.
205 #[must_use]
206 pub fn resolved_capability_profile_with_overrides(
207 provider: ProviderKind,
208 wire_model_id: &str,
209 overrides: CapabilityOverride,
210 ) -> CapabilityProfile {
211 let model = model_profile(wire_model_id);
212 let provider_cap = provider_capability(provider, wire_model_id);
213 let request_payload_mode = provider_cap.request_payload_mode;
214 let context_window = Some(
215 overrides
216 .context_window
217 .unwrap_or(provider_cap.context_window),
218 );
219 // An explicit override wins; otherwise carry the compatibility cap through
220 // *including its unknown state*, so pickers and diagnostics render `?`
221 // rather than a fabricated ceiling.
222 let max_output = overrides.max_output.or(provider_cap.max_output);
223 let reasoning = overrides
224 .reasoning
225 .unwrap_or_else(|| bool_state(provider_cap.thinking_supported));
226 let prompt_caching = overrides
227 .prompt_caching
228 .unwrap_or_else(|| bool_state(provider_cap.cache_telemetry_supported));
229 let image_input = overrides.image_input.unwrap_or(SupportState::Unknown);
230 let native_tool_calls = overrides
231 .native_tool_calls
232 .unwrap_or_else(|| native_tool_support_for_payload(request_payload_mode));
233 let structured_output = overrides
234 .structured_output
235 .unwrap_or(model.capabilities.structured_output);
236 let streaming = overrides.streaming.unwrap_or(SupportState::Supported);
237 let parallel_tool_calls = overrides
238 .parallel_tool_calls
239 .unwrap_or(model.capabilities.parallel_tool_calls);
240 let tool_surface_budget = overrides
241 .tool_surface_budget
242 .unwrap_or_else(|| tool_surface_for_window(context_window));
243
244 let mut provenance = vec![model.provenance, FactProvenance::LegacyProviderFallback];
245 if overrides != CapabilityOverride::default() {
246 provenance.push(FactProvenance::UserOverride);
247 }
248
249 CapabilityProfile {
250 provider,
251 canonical_model: Some(model.canonical_id),
252 wire_model_id: wire_model_id.to_string(),
253 request_payload_mode,
254 context_window,
255 max_output,
256 reasoning,
257 native_tool_calls,
258 parallel_tool_calls,
259 structured_output,
260 streaming,
261 prompt_caching,
262 image_input,
263 tool_surface_budget,
264 provenance,
265 }
266 }
267
268 /// Resolve capabilities for an exact route candidate.
269 ///
270 /// Sourced route facts and limits win. Legacy provider/model behavior is used
271 /// only for fields the selected offering leaves `Unknown`, and that fallback
272 /// remains visible in provenance.
273 #[must_use]
274 pub fn resolved_capability_profile_for_route(
275 provider: ProviderKind,
276 wire_model_id: &str,
277 route_capabilities: RouteCapabilities,
278 route_limits: RouteLimits,
279 ) -> CapabilityProfile {
280 let mut profile = resolved_capability_profile(provider, wire_model_id);
281 profile.context_window = route_limits
282 .context_tokens
283 .and_then(|tokens| u32::try_from(tokens).ok())
284 .or(profile.context_window);
285 profile.max_output = route_limits
286 .output_tokens
287 .and_then(|tokens| u32::try_from(tokens).ok())
288 .or(profile.max_output);
289 profile.reasoning = route_fact_or_fallback(route_capabilities.reasoning, profile.reasoning);
290 profile.native_tool_calls = route_fact_or_fallback(
291 route_capabilities.native_tool_calls,
292 profile.native_tool_calls,
293 );
294 profile.parallel_tool_calls = route_fact_or_fallback(
295 route_capabilities.parallel_tool_calls,
296 profile.parallel_tool_calls,
297 );
298 profile.structured_output = route_fact_or_fallback(
299 route_capabilities.structured_output,
300 profile.structured_output,
301 );
302 profile.streaming = route_fact_or_fallback(route_capabilities.streaming, profile.streaming);
303 profile.prompt_caching =
304 route_fact_or_fallback(route_capabilities.prompt_caching, profile.prompt_caching);
305 profile.image_input =
306 route_fact_or_fallback(route_capabilities.image_input, profile.image_input);
307 profile.tool_surface_budget = tool_surface_for_window(profile.context_window);
308 profile
309 .provenance
310 .insert(0, FactProvenance::ResolvedRouteCandidate);
311 profile
312 }
313
314 /// Resolve an exact route profile, then apply explicit user/config overrides.
315 #[must_use]
316 pub fn resolved_capability_profile_for_route_with_overrides(
317 provider: ProviderKind,
318 wire_model_id: &str,
319 route_capabilities: RouteCapabilities,
320 route_limits: RouteLimits,
321 overrides: CapabilityOverride,
322 ) -> CapabilityProfile {
323 let mut profile = resolved_capability_profile_for_route(
324 provider,
325 wire_model_id,
326 route_capabilities,
327 route_limits,
328 );
329 if let Some(context_window) = overrides.context_window {
330 profile.context_window = Some(context_window);
331 }
332 if let Some(max_output) = overrides.max_output {
333 profile.max_output = Some(max_output);
334 }
335 if let Some(reasoning) = overrides.reasoning {
336 profile.reasoning = reasoning;
337 }
338 if let Some(native_tool_calls) = overrides.native_tool_calls {
339 profile.native_tool_calls = native_tool_calls;
340 }
341 if let Some(parallel_tool_calls) = overrides.parallel_tool_calls {
342 profile.parallel_tool_calls = parallel_tool_calls;
343 }
344 if let Some(structured_output) = overrides.structured_output {
345 profile.structured_output = structured_output;
346 }
347 if let Some(streaming) = overrides.streaming {
348 profile.streaming = streaming;
349 }
350 if let Some(prompt_caching) = overrides.prompt_caching {
351 profile.prompt_caching = prompt_caching;
352 }
353 if let Some(image_input) = overrides.image_input {
354 profile.image_input = image_input;
355 }
356 profile.tool_surface_budget = overrides
357 .tool_surface_budget
358 .unwrap_or_else(|| tool_surface_for_window(profile.context_window));
359 if overrides != CapabilityOverride::default() {
360 profile.provenance.push(FactProvenance::UserOverride);
361 }
362 profile
363 }
364
365 const fn bool_state(value: bool) -> SupportState {
366 if value {
367 SupportState::Supported
368 } else {
369 SupportState::Unsupported
370 }
371 }
372
373 const fn route_fact_or_fallback(route_fact: SupportState, fallback: SupportState) -> SupportState {
374 match route_fact {
375 SupportState::Unknown => fallback,
376 sourced => sourced,
377 }
378 }
379
380 #[must_use]
381 pub fn tool_surface_for_window(context_window: Option<u32>) -> ToolSurfaceBudget {
382 match context_window {
383 Some(window) if window >= 400_000 => ToolSurfaceBudget::Full,
384 Some(window) if window >= 128_000 => ToolSurfaceBudget::Standard,
385 _ => ToolSurfaceBudget::Compact,
386 }
387 }
388
389 fn native_tool_support_for_payload(mode: RequestPayloadMode) -> SupportState {
390 match mode {
391 RequestPayloadMode::ChatCompletions
392 | RequestPayloadMode::Responses
393 | RequestPayloadMode::AnthropicMessages => SupportState::Supported,
394 }
395 }
396
397 fn display_name(model: &str) -> String {
398 model
399 .rsplit(['/', ':'])
400 .next()
401 .filter(|name| !name.is_empty())
402 .unwrap_or(model)
403 .to_string()
404 }
405
406 #[cfg(test)]
407 mod tests {
408 use super::*;
409
410 #[test]
411 fn model_profile_known_lookup_uses_seeded_model_facts() {
412 let profile = model_profile("deepseek-v4-pro");
413
414 assert_eq!(profile.canonical_id, "deepseek-v4-pro");
415 assert_eq!(profile.family, Some(ModelProvider::DeepSeek));
416 assert_eq!(profile.capabilities.context_window, Some(1_000_000));
417 assert_eq!(profile.capabilities.max_output, Some(384_000));
418 assert_eq!(profile.capabilities.reasoning, SupportState::Supported);
419 assert_eq!(
420 profile.capabilities.tool_surface_budget,
421 ToolSurfaceBudget::Full
422 );
423 assert_eq!(profile.provenance, FactProvenance::SeededModelRegistry);
424 }
425
426 #[test]
427 fn model_profile_unknown_fallback_is_conservative() {
428 let profile = model_profile("custom-local-model");
429
430 assert_eq!(profile.canonical_id, "custom-local-model");
431 assert_eq!(profile.family, None);
432 assert_eq!(profile.capabilities.context_window, None);
433 assert_eq!(profile.capabilities.reasoning, SupportState::Unknown);
434 assert_eq!(
435 profile.capabilities.native_tool_calls,
436 SupportState::Unknown
437 );
438 assert_eq!(
439 profile.capabilities.tool_surface_budget,
440 ToolSurfaceBudget::Compact
441 );
442 assert_eq!(
443 profile.provenance,
444 FactProvenance::ConservativeUnknownFallback
445 );
446 }
447
448 #[test]
449 fn resolved_capability_profile_merges_provider_facts_and_overrides() {
450 let profile = resolved_capability_profile_with_overrides(
451 ProviderKind::OpenaiCodex,
452 "gpt-5-codex",
453 CapabilityOverride {
454 context_window: Some(123_456),
455 reasoning: Some(SupportState::Unsupported),
456 tool_surface_budget: Some(ToolSurfaceBudget::Compact),
457 ..CapabilityOverride::default()
458 },
459 );
460
461 assert_eq!(profile.provider, ProviderKind::OpenaiCodex);
462 assert_eq!(profile.request_payload_mode, RequestPayloadMode::Responses);
463 assert_eq!(profile.context_window, Some(123_456));
464 assert_eq!(profile.reasoning, SupportState::Unsupported);
465 assert_eq!(profile.native_tool_calls, SupportState::Supported);
466 assert_eq!(profile.tool_surface_budget, ToolSurfaceBudget::Compact);
467 assert!(profile.provenance.contains(&FactProvenance::UserOverride));
468 }
469
470 #[test]
471 fn capability_predicates_are_not_provider_string_checks() {
472 let broad = resolved_capability_profile(ProviderKind::Deepseek, "deepseek-v4-pro");
473 let compact = resolved_capability_profile_with_overrides(
474 ProviderKind::Openrouter,
475 "unknown-small-model",
476 CapabilityOverride {
477 context_window: Some(32_000),
478 native_tool_calls: Some(SupportState::Unknown),
479 tool_surface_budget: Some(ToolSurfaceBudget::Compact),
480 ..CapabilityOverride::default()
481 },
482 );
483
484 assert!(broad.has_large_context());
485 assert!(broad.prefers_full_tool_surface());
486 assert!(broad.suitable_for_broad_fleet_worker());
487 assert!(!compact.has_large_context());
488 assert!(!compact.prefers_full_tool_surface());
489 assert!(!compact.suitable_for_broad_fleet_worker());
490 }
491
492 #[test]
493 fn exact_route_facts_override_legacy_provider_heuristics() {
494 let profile = resolved_capability_profile_for_route(
495 ProviderKind::Openai,
496 "gpt-5.4",
497 RouteCapabilities {
498 reasoning: SupportState::Unsupported,
499 native_tool_calls: SupportState::Unsupported,
500 structured_output: SupportState::Supported,
501 ..RouteCapabilities::default()
502 },
503 RouteLimits {
504 context_tokens: Some(42_000),
505 input_tokens: None,
506 output_tokens: Some(7_000),
507 },
508 );
509
510 assert_eq!(profile.context_window, Some(42_000));
511 assert_eq!(profile.max_output, Some(7_000));
512 assert_eq!(profile.reasoning, SupportState::Unsupported);
513 assert_eq!(profile.native_tool_calls, SupportState::Unsupported);
514 assert_eq!(profile.image_input, SupportState::Unknown);
515 assert_eq!(profile.structured_output, SupportState::Supported);
516 assert!(
517 profile
518 .provenance
519 .starts_with(&[FactProvenance::ResolvedRouteCandidate])
520 );
521 assert!(
522 profile
523 .provenance
524 .contains(&FactProvenance::LegacyProviderFallback)
525 );
526 }
527
528 #[test]
529 fn explicit_override_wins_after_exact_route_fact() {
530 let profile = resolved_capability_profile_for_route_with_overrides(
531 ProviderKind::Openai,
532 "gpt-5.4",
533 RouteCapabilities {
534 reasoning: SupportState::Unsupported,
535 ..RouteCapabilities::default()
536 },
537 RouteLimits::default(),
538 CapabilityOverride {
539 reasoning: Some(SupportState::Supported),
540 ..CapabilityOverride::default()
541 },
542 );
543
544 assert_eq!(profile.reasoning, SupportState::Supported);
545 assert_eq!(
546 profile.provenance.last(),
547 Some(&FactProvenance::UserOverride)
548 );
549 }
550
551 #[test]
552 fn image_input_route_fact_and_override_are_explicit() {
553 let sourced = resolved_capability_profile_for_route(
554 ProviderKind::Openai,
555 "vision-fixture",
556 RouteCapabilities {
557 image_input: SupportState::Supported,
558 ..RouteCapabilities::default()
559 },
560 RouteLimits::default(),
561 );
562 assert!(sourced.supports_image_input());
563
564 let overridden = resolved_capability_profile_for_route_with_overrides(
565 ProviderKind::Openai,
566 "vision-fixture",
567 RouteCapabilities::default(),
568 RouteLimits::default(),
569 CapabilityOverride {
570 image_input: Some(SupportState::Supported),
571 ..CapabilityOverride::default()
572 },
573 );
574 assert!(overridden.supports_image_input());
575 }
576 }
577
577 lines RUST