返回 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 [`crate::model_registry`] 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::{ApiProvider, RequestPayloadMode, provider_capability};
12 use crate::model_registry::{self, 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: ApiProvider,
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 model_registry::lookup(trimmed) {
139 Some(meta) => {
140 let canonical_id = if meta.id.is_empty() {
141 trimmed.to_string()
142 } else {
143 meta.id.to_string()
144 };
145 let provenance = if meta.id.is_empty() {
146 FactProvenance::LegacyModelHeuristics
147 } else {
148 FactProvenance::SeededModelRegistry
149 };
150 ModelProfile {
151 canonical_id,
152 display_name,
153 aliases: Vec::new(),
154 family: Some(meta.provider),
155 capabilities: IntrinsicCapabilityProfile {
156 context_window: meta.context_window,
157 max_output: meta.max_output,
158 reasoning: bool_state(meta.supports_reasoning),
159 native_tool_calls: SupportState::Unknown,
160 parallel_tool_calls: SupportState::Unknown,
161 structured_output: SupportState::Unknown,
162 streaming: SupportState::Supported,
163 prompt_caching: SupportState::Unknown,
164 image_input: SupportState::Unknown,
165 tool_surface_budget: tool_surface_for_window(meta.context_window),
166 },
167 provenance,
168 }
169 }
170 None => ModelProfile {
171 canonical_id: trimmed.to_string(),
172 display_name,
173 aliases: Vec::new(),
174 family: None,
175 capabilities: IntrinsicCapabilityProfile {
176 context_window: None,
177 max_output: None,
178 reasoning: SupportState::Unknown,
179 native_tool_calls: SupportState::Unknown,
180 parallel_tool_calls: SupportState::Unknown,
181 structured_output: SupportState::Unknown,
182 streaming: SupportState::Unknown,
183 prompt_caching: SupportState::Unknown,
184 image_input: SupportState::Unknown,
185 tool_surface_budget: ToolSurfaceBudget::Compact,
186 },
187 provenance: FactProvenance::ConservativeUnknownFallback,
188 },
189 }
190 }
191
192 /// Resolve a profile from legacy model/provider heuristics.
193 ///
194 /// This remains a picker/startup fallback for call sites that do not yet hold
195 /// an executable route candidate. Runtime execution should use
196 /// [`resolved_capability_profile_for_route`], where exact offering facts win.
197 #[must_use]
198 pub fn resolved_capability_profile(
199 provider: ApiProvider,
200 wire_model_id: &str,
201 ) -> CapabilityProfile {
202 resolved_capability_profile_with_overrides(
203 provider,
204 wire_model_id,
205 CapabilityOverride::default(),
206 )
207 }
208
209 /// Resolve legacy fallback capabilities and apply explicit overrides last.
210 #[must_use]
211 pub fn resolved_capability_profile_with_overrides(
212 provider: ApiProvider,
213 wire_model_id: &str,
214 overrides: CapabilityOverride,
215 ) -> CapabilityProfile {
216 let model = model_profile(wire_model_id);
217 let provider_cap = provider_capability(provider, wire_model_id);
218 let request_payload_mode = provider_cap.request_payload_mode;
219 let context_window = Some(
220 overrides
221 .context_window
222 .unwrap_or(provider_cap.context_window),
223 );
224 // An explicit override wins; otherwise carry the compatibility cap through
225 // *including its unknown state*, so pickers and diagnostics render `?`
226 // rather than a fabricated ceiling.
227 let max_output = overrides.max_output.or(provider_cap.max_output);
228 let reasoning = overrides
229 .reasoning
230 .unwrap_or_else(|| bool_state(provider_cap.thinking_supported));
231 let prompt_caching = overrides
232 .prompt_caching
233 .unwrap_or_else(|| bool_state(provider_cap.cache_telemetry_supported));
234 let image_input = overrides.image_input.unwrap_or(SupportState::Unknown);
235 let native_tool_calls = overrides
236 .native_tool_calls
237 .unwrap_or_else(|| native_tool_support_for_payload(request_payload_mode));
238 let structured_output = overrides
239 .structured_output
240 .unwrap_or(model.capabilities.structured_output);
241 let streaming = overrides.streaming.unwrap_or(SupportState::Supported);
242 let parallel_tool_calls = overrides
243 .parallel_tool_calls
244 .unwrap_or(model.capabilities.parallel_tool_calls);
245 let tool_surface_budget = overrides
246 .tool_surface_budget
247 .unwrap_or_else(|| tool_surface_for_window(context_window));
248
249 let mut provenance = vec![model.provenance, FactProvenance::LegacyProviderFallback];
250 if overrides != CapabilityOverride::default() {
251 provenance.push(FactProvenance::UserOverride);
252 }
253
254 CapabilityProfile {
255 provider,
256 canonical_model: Some(model.canonical_id),
257 wire_model_id: wire_model_id.to_string(),
258 request_payload_mode,
259 context_window,
260 max_output,
261 reasoning,
262 native_tool_calls,
263 parallel_tool_calls,
264 structured_output,
265 streaming,
266 prompt_caching,
267 image_input,
268 tool_surface_budget,
269 provenance,
270 }
271 }
272
273 /// Resolve capabilities for an exact route candidate.
274 ///
275 /// Sourced route facts and limits win. Legacy provider/model behavior is used
276 /// only for fields the selected offering leaves `Unknown`, and that fallback
277 /// remains visible in provenance.
278 #[must_use]
279 pub fn resolved_capability_profile_for_route(
280 provider: ApiProvider,
281 wire_model_id: &str,
282 route_capabilities: RouteCapabilities,
283 route_limits: RouteLimits,
284 ) -> CapabilityProfile {
285 let mut profile = resolved_capability_profile(provider, wire_model_id);
286 profile.context_window = route_limits
287 .context_tokens
288 .and_then(|tokens| u32::try_from(tokens).ok())
289 .or(profile.context_window);
290 profile.max_output = route_limits
291 .output_tokens
292 .and_then(|tokens| u32::try_from(tokens).ok())
293 .or(profile.max_output);
294 profile.reasoning = route_fact_or_fallback(route_capabilities.reasoning, profile.reasoning);
295 profile.native_tool_calls = route_fact_or_fallback(
296 route_capabilities.native_tool_calls,
297 profile.native_tool_calls,
298 );
299 profile.parallel_tool_calls = route_fact_or_fallback(
300 route_capabilities.parallel_tool_calls,
301 profile.parallel_tool_calls,
302 );
303 profile.structured_output = route_fact_or_fallback(
304 route_capabilities.structured_output,
305 profile.structured_output,
306 );
307 profile.streaming = route_fact_or_fallback(route_capabilities.streaming, profile.streaming);
308 profile.prompt_caching =
309 route_fact_or_fallback(route_capabilities.prompt_caching, profile.prompt_caching);
310 profile.image_input =
311 route_fact_or_fallback(route_capabilities.image_input, profile.image_input);
312 profile.tool_surface_budget = tool_surface_for_window(profile.context_window);
313 profile
314 .provenance
315 .insert(0, FactProvenance::ResolvedRouteCandidate);
316 profile
317 }
318
319 /// Resolve an exact route profile, then apply explicit user/config overrides.
320 #[must_use]
321 pub fn resolved_capability_profile_for_route_with_overrides(
322 provider: ApiProvider,
323 wire_model_id: &str,
324 route_capabilities: RouteCapabilities,
325 route_limits: RouteLimits,
326 overrides: CapabilityOverride,
327 ) -> CapabilityProfile {
328 let mut profile = resolved_capability_profile_for_route(
329 provider,
330 wire_model_id,
331 route_capabilities,
332 route_limits,
333 );
334 if let Some(context_window) = overrides.context_window {
335 profile.context_window = Some(context_window);
336 }
337 if let Some(max_output) = overrides.max_output {
338 profile.max_output = Some(max_output);
339 }
340 if let Some(reasoning) = overrides.reasoning {
341 profile.reasoning = reasoning;
342 }
343 if let Some(native_tool_calls) = overrides.native_tool_calls {
344 profile.native_tool_calls = native_tool_calls;
345 }
346 if let Some(parallel_tool_calls) = overrides.parallel_tool_calls {
347 profile.parallel_tool_calls = parallel_tool_calls;
348 }
349 if let Some(structured_output) = overrides.structured_output {
350 profile.structured_output = structured_output;
351 }
352 if let Some(streaming) = overrides.streaming {
353 profile.streaming = streaming;
354 }
355 if let Some(prompt_caching) = overrides.prompt_caching {
356 profile.prompt_caching = prompt_caching;
357 }
358 if let Some(image_input) = overrides.image_input {
359 profile.image_input = image_input;
360 }
361 profile.tool_surface_budget = overrides
362 .tool_surface_budget
363 .unwrap_or_else(|| tool_surface_for_window(profile.context_window));
364 if overrides != CapabilityOverride::default() {
365 profile.provenance.push(FactProvenance::UserOverride);
366 }
367 profile
368 }
369
370 const fn bool_state(value: bool) -> SupportState {
371 if value {
372 SupportState::Supported
373 } else {
374 SupportState::Unsupported
375 }
376 }
377
378 const fn route_fact_or_fallback(route_fact: SupportState, fallback: SupportState) -> SupportState {
379 match route_fact {
380 SupportState::Unknown => fallback,
381 sourced => sourced,
382 }
383 }
384
385 #[must_use]
386 pub fn tool_surface_for_window(context_window: Option<u32>) -> ToolSurfaceBudget {
387 match context_window {
388 Some(window) if window >= 400_000 => ToolSurfaceBudget::Full,
389 Some(window) if window >= 128_000 => ToolSurfaceBudget::Standard,
390 _ => ToolSurfaceBudget::Compact,
391 }
392 }
393
394 fn native_tool_support_for_payload(mode: RequestPayloadMode) -> SupportState {
395 match mode {
396 RequestPayloadMode::ChatCompletions
397 | RequestPayloadMode::Responses
398 | RequestPayloadMode::AnthropicMessages => SupportState::Supported,
399 }
400 }
401
402 fn display_name(model: &str) -> String {
403 model
404 .rsplit(['/', ':'])
405 .next()
406 .filter(|name| !name.is_empty())
407 .unwrap_or(model)
408 .to_string()
409 }
410
411 #[cfg(test)]
412 mod tests {
413 use super::*;
414
415 #[test]
416 fn model_profile_known_lookup_uses_seeded_model_facts() {
417 let profile = model_profile("deepseek-v4-pro");
418
419 assert_eq!(profile.canonical_id, "deepseek-v4-pro");
420 assert_eq!(profile.family, Some(ModelProvider::DeepSeek));
421 assert_eq!(profile.capabilities.context_window, Some(1_000_000));
422 assert_eq!(profile.capabilities.max_output, Some(384_000));
423 assert_eq!(profile.capabilities.reasoning, SupportState::Supported);
424 assert_eq!(
425 profile.capabilities.tool_surface_budget,
426 ToolSurfaceBudget::Full
427 );
428 assert_eq!(profile.provenance, FactProvenance::SeededModelRegistry);
429 }
430
431 #[test]
432 fn model_profile_unknown_fallback_is_conservative() {
433 let profile = model_profile("custom-local-model");
434
435 assert_eq!(profile.canonical_id, "custom-local-model");
436 assert_eq!(profile.family, None);
437 assert_eq!(profile.capabilities.context_window, None);
438 assert_eq!(profile.capabilities.reasoning, SupportState::Unknown);
439 assert_eq!(
440 profile.capabilities.native_tool_calls,
441 SupportState::Unknown
442 );
443 assert_eq!(
444 profile.capabilities.tool_surface_budget,
445 ToolSurfaceBudget::Compact
446 );
447 assert_eq!(
448 profile.provenance,
449 FactProvenance::ConservativeUnknownFallback
450 );
451 }
452
453 #[test]
454 fn resolved_capability_profile_merges_provider_facts_and_overrides() {
455 let profile = resolved_capability_profile_with_overrides(
456 ApiProvider::OpenaiCodex,
457 "gpt-5-codex",
458 CapabilityOverride {
459 context_window: Some(123_456),
460 reasoning: Some(SupportState::Unsupported),
461 tool_surface_budget: Some(ToolSurfaceBudget::Compact),
462 ..CapabilityOverride::default()
463 },
464 );
465
466 assert_eq!(profile.provider, ApiProvider::OpenaiCodex);
467 assert_eq!(profile.request_payload_mode, RequestPayloadMode::Responses);
468 assert_eq!(profile.context_window, Some(123_456));
469 assert_eq!(profile.reasoning, SupportState::Unsupported);
470 assert_eq!(profile.native_tool_calls, SupportState::Supported);
471 assert_eq!(profile.tool_surface_budget, ToolSurfaceBudget::Compact);
472 assert!(profile.provenance.contains(&FactProvenance::UserOverride));
473 }
474
475 #[test]
476 fn capability_predicates_are_not_provider_string_checks() {
477 let broad = resolved_capability_profile(ApiProvider::Deepseek, "deepseek-v4-pro");
478 let compact = resolved_capability_profile_with_overrides(
479 ApiProvider::Openrouter,
480 "unknown-small-model",
481 CapabilityOverride {
482 context_window: Some(32_000),
483 native_tool_calls: Some(SupportState::Unknown),
484 tool_surface_budget: Some(ToolSurfaceBudget::Compact),
485 ..CapabilityOverride::default()
486 },
487 );
488
489 assert!(broad.has_large_context());
490 assert!(broad.prefers_full_tool_surface());
491 assert!(broad.suitable_for_broad_fleet_worker());
492 assert!(!compact.has_large_context());
493 assert!(!compact.prefers_full_tool_surface());
494 assert!(!compact.suitable_for_broad_fleet_worker());
495 }
496
497 #[test]
498 fn exact_route_facts_override_legacy_provider_heuristics() {
499 let profile = resolved_capability_profile_for_route(
500 ApiProvider::Openai,
501 "gpt-5.4",
502 RouteCapabilities {
503 reasoning: SupportState::Unsupported,
504 native_tool_calls: SupportState::Unsupported,
505 structured_output: SupportState::Supported,
506 ..RouteCapabilities::default()
507 },
508 RouteLimits {
509 context_tokens: Some(42_000),
510 input_tokens: None,
511 output_tokens: Some(7_000),
512 },
513 );
514
515 assert_eq!(profile.context_window, Some(42_000));
516 assert_eq!(profile.max_output, Some(7_000));
517 assert_eq!(profile.reasoning, SupportState::Unsupported);
518 assert_eq!(profile.native_tool_calls, SupportState::Unsupported);
519 assert_eq!(profile.image_input, SupportState::Unknown);
520 assert_eq!(profile.structured_output, SupportState::Supported);
521 assert!(
522 profile
523 .provenance
524 .starts_with(&[FactProvenance::ResolvedRouteCandidate])
525 );
526 assert!(
527 profile
528 .provenance
529 .contains(&FactProvenance::LegacyProviderFallback)
530 );
531 }
532
533 #[test]
534 fn explicit_override_wins_after_exact_route_fact() {
535 let profile = resolved_capability_profile_for_route_with_overrides(
536 ApiProvider::Openai,
537 "gpt-5.4",
538 RouteCapabilities {
539 reasoning: SupportState::Unsupported,
540 ..RouteCapabilities::default()
541 },
542 RouteLimits::default(),
543 CapabilityOverride {
544 reasoning: Some(SupportState::Supported),
545 ..CapabilityOverride::default()
546 },
547 );
548
549 assert_eq!(profile.reasoning, SupportState::Supported);
550 assert_eq!(
551 profile.provenance.last(),
552 Some(&FactProvenance::UserOverride)
553 );
554 }
555
556 #[test]
557 fn image_input_route_fact_and_override_are_explicit() {
558 let sourced = resolved_capability_profile_for_route(
559 ApiProvider::Openai,
560 "vision-fixture",
561 RouteCapabilities {
562 image_input: SupportState::Supported,
563 ..RouteCapabilities::default()
564 },
565 RouteLimits::default(),
566 );
567 assert!(sourced.supports_image_input());
568
569 let overridden = resolved_capability_profile_for_route_with_overrides(
570 ApiProvider::Openai,
571 "vision-fixture",
572 RouteCapabilities::default(),
573 RouteLimits::default(),
574 CapabilityOverride {
575 image_input: Some(SupportState::Supported),
576 ..CapabilityOverride::default()
577 },
578 );
579 assert!(overridden.supports_image_input());
580 }
581 }
582
582 lines RUST