diff --git a/crates/tui/src/tui/provider_picker.rs b/crates/tui/src/tui/provider_picker.rs index 12bbfbb95f..40a3e2590a 100644 --- a/crates/tui/src/tui/provider_picker.rs +++ b/crates/tui/src/tui/provider_picker.rs @@ -27,12 +27,17 @@ use ratatui::{ }; use crate::config::{ApiProvider, Config, has_api_key_for, kimi_cli_credentials_present}; +use crate::model_profile::{SupportState, resolved_capability_profile}; use crate::palette; +use crate::tui::app::ReasoningEffort; use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent}; +use codewhale_config::catalog::{CatalogOffering, CatalogSnapshot}; use codewhale_config::provider::WireFormat; use codewhale_config::route::{ LogicalModelRef, PricingSku, RequestProtocol, RouteRequest, RouteResolver, bundled_offerings, }; +use serde_json::Value; +use std::sync::OnceLock; #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Stage { @@ -60,6 +65,9 @@ pub struct ProviderDashboardRow { pub available_model_count: usize, pub default_route: ProviderDefaultRoute, pub usage_meter: String, + pub reasoning: ProviderReasoningSummary, + pub capabilities: ProviderCapabilityBadges, + pub model_origin: ProviderModelOrigin, pub readiness: ProviderReadiness, pub maturity: ProviderMaturity, pub messages: Vec, @@ -130,6 +138,132 @@ impl ProviderMaturity { } } +/// Where the row's current model came from, so the dashboard can distinguish a +/// provider default from a saved override or a custom pass-through id (#3083). +/// Live-catalog/static origins are not yet distinguishable here; they arrive +/// with the #3385 live-fetch layer. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProviderModelOrigin { + Default, + Saved, + Custom, +} + +impl ProviderModelOrigin { + fn for_provider(provider: ApiProvider, has_saved_model: bool) -> Self { + if has_saved_model { + Self::Saved + } else if provider == ApiProvider::Custom { + Self::Custom + } else { + Self::Default + } + } + + fn label(self) -> &'static str { + match self { + Self::Default => "default", + Self::Saved => "saved", + Self::Custom => "custom", + } + } +} + +/// Capability + metadata badges projected from the resolved capability profile +/// (#3083). Tri-state so "unknown" stays distinct from "unsupported"; metadata +/// is `None` when not resolvable. Reasoning is tracked separately in +/// [`ProviderReasoningSummary`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProviderCapabilityBadges { + pub context_window: Option, + pub max_output: Option, + pub tools: SupportState, + pub structured: SupportState, + pub streaming: SupportState, + pub cache: SupportState, +} + +impl ProviderCapabilityBadges { + fn for_route(provider: ApiProvider, wire_model: &str) -> Self { + let cap = resolved_capability_profile(provider, wire_model); + Self { + context_window: cap.context_window, + max_output: cap.max_output, + tools: cap.native_tool_calls, + structured: cap.structured_output, + streaming: cap.streaming, + cache: cap.prompt_caching, + } + } + + fn unknown() -> Self { + Self { + context_window: None, + max_output: None, + tools: SupportState::Unknown, + structured: SupportState::Unknown, + streaming: SupportState::Unknown, + cache: SupportState::Unknown, + } + } + + /// Compact, never-fabricating badge cluster. Metadata and each capability + /// render `?` when unknown rather than being silently dropped. + fn label(&self) -> String { + format!( + "ctx:{} out:{} tools:{} json:{} stream:{} cache:{}", + humanize_token_count(self.context_window), + humanize_token_count(self.max_output), + support_glyph(self.tools), + support_glyph(self.structured), + support_glyph(self.streaming), + support_glyph(self.cache), + ) + } +} + +fn support_glyph(state: SupportState) -> &'static str { + match state { + SupportState::Supported => "y", + SupportState::Unsupported => "n", + SupportState::Unknown => "?", + } +} + +fn humanize_token_count(value: Option) -> String { + match value { + None => "?".to_string(), + Some(v) if v >= 1_000_000 && v % 1_000_000 == 0 => format!("{}M", v / 1_000_000), + Some(v) if v >= 1_000_000 => format!("{:.1}M", f64::from(v) / 1_000_000.0), + Some(v) if v >= 1_000 => format!("{}K", v / 1_000), + Some(v) => v.to_string(), + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProviderReasoningSummary { + pub support: ProviderReasoningSupport, + pub controls: Vec, + pub stream_visibility: ProviderReasoningStreamVisibility, + pub selected_control: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProviderReasoningSupport { + Supported, + Unsupported, + Unknown, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProviderReasoningStreamVisibility { + StructuredThinking, + InlineTags, + SummaryOnly, + NotExposed, + Unknown, +} + impl ProviderDashboardRow { fn from_config(provider: ApiProvider, active: ApiProvider, config: &Config) -> Self { let has_key = has_api_key_for(config, provider); @@ -144,6 +278,8 @@ impl ProviderDashboardRow { .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string); + let has_configured_model = configured_model.is_some(); + let model_origin = ProviderModelOrigin::for_provider(provider, has_configured_model); let auth_status = auth_status_for(provider, has_key, configured); let usage_meter = usage_meter_for(provider); @@ -165,6 +301,9 @@ impl ProviderDashboardRow { wire_model: "legacy alias".to_string(), }, usage_meter, + reasoning: ProviderReasoningSummary::unknown(provider, config), + capabilities: ProviderCapabilityBadges::unknown(), + model_origin, readiness: ProviderReadiness::Legacy, maturity: ProviderMaturity::for_provider(provider), messages: vec![ @@ -244,6 +383,8 @@ impl ProviderDashboardRow { } let readiness = readiness_for(provider, auth_status, route_ok); + let reasoning = ProviderReasoningSummary::for_route(provider, &default_route, config); + let capabilities = ProviderCapabilityBadges::for_route(provider, &default_route.wire_model); Self { provider, @@ -257,6 +398,9 @@ impl ProviderDashboardRow { available_model_count, default_route, usage_meter: resolved_pricing, + reasoning, + capabilities, + model_origin, readiness, maturity: ProviderMaturity::for_provider(provider), messages, @@ -266,15 +410,29 @@ impl ProviderDashboardRow { } fn compact_hint(&self) -> String { + // Self-hosted providers carry a local/private posture; surface it next + // to the base URL so the row reads correctly without a key (#3083). + let self_hosted = if matches!( + self.auth_status, + ProviderAuthStatus::Local | ProviderAuthStatus::Optional + ) { + " (self-hosted)" + } else { + "" + }; format!( - "{} | auth:{} | {} | {} | base:{} | route:{}{} | catalog:{}{}", + "{} | auth:{} | {} | {} | base:{}{} | route:{}{} origin:{} | {} | {} | catalog:{}{}", self.readiness.label(), self.auth_status.label(), self.usage_meter, self.supported_protocols.join("+"), compact_base_url(&self.base_url), + self_hosted, self.default_route.logical_model, route_wire_suffix(&self.default_route), + self.model_origin.label(), + self.capabilities.label(), + self.reasoning.label(), self.catalog_label(), // Only experimental integrations add a tag; supported ones stay // noise-free (#2984). @@ -294,6 +452,82 @@ impl ProviderDashboardRow { } } +impl ProviderReasoningSummary { + fn for_route(provider: ApiProvider, route: &ProviderDefaultRoute, config: &Config) -> Self { + if provider == ApiProvider::OpenaiCodex { + return Self { + support: ProviderReasoningSupport::Supported, + controls: codex_reasoning_controls(), + stream_visibility: ProviderReasoningStreamVisibility::StructuredThinking, + selected_control: selected_reasoning_control(provider, config), + }; + } + + if let Some(offering) = reasoning_catalog_offering(provider, route) { + let support = match offering.reasoning { + Some(true) => ProviderReasoningSupport::Supported, + Some(false) => ProviderReasoningSupport::Unsupported, + None => ProviderReasoningSupport::Unknown, + }; + let controls = reasoning_controls_from_options(&offering.reasoning_options); + return Self { + support, + controls, + stream_visibility: configured_or_default_stream_visibility( + provider, config, support, + ), + selected_control: selected_reasoning_control(provider, config), + }; + } + + Self::unknown(provider, config) + } + + fn unknown(provider: ApiProvider, config: &Config) -> Self { + Self { + support: ProviderReasoningSupport::Unknown, + controls: Vec::new(), + stream_visibility: configured_or_default_stream_visibility( + provider, + config, + ProviderReasoningSupport::Unknown, + ), + selected_control: selected_reasoning_control(provider, config), + } + } + + fn label(&self) -> String { + let support = match self.support { + ProviderReasoningSupport::Supported if !self.controls.is_empty() => { + format!("reasoning:{}", self.controls.join("/")) + } + ProviderReasoningSupport::Supported => "reasoning:yes".to_string(), + ProviderReasoningSupport::Unsupported => "reasoning:no".to_string(), + ProviderReasoningSupport::Unknown => "reasoning:unknown".to_string(), + }; + let mut parts = vec![ + support, + format!("stream:{}", self.stream_visibility.label()), + ]; + if let Some(selected) = &self.selected_control { + parts.push(format!("ctrl:{selected}")); + } + parts.join(" ") + } +} + +impl ProviderReasoningStreamVisibility { + fn label(self) -> &'static str { + match self { + Self::StructuredThinking => "structured", + Self::InlineTags => "inline-tags", + Self::SummaryOnly => "summary-only", + Self::NotExposed => "not-exposed", + Self::Unknown => "unknown", + } + } +} + impl ProviderAuthStatus { fn label(self) -> &'static str { match self { @@ -320,6 +554,147 @@ impl ProviderReadiness { } } +fn reasoning_catalog_offering( + provider: ApiProvider, + route: &ProviderDefaultRoute, +) -> Option<&'static CatalogOffering> { + let provider_id = provider.kind()?.as_str(); + bundled_reasoning_catalog() + .offerings + .iter() + .find(|offering| { + offering.provider == provider_id + && offering + .wire_model_id + .eq_ignore_ascii_case(&route.wire_model) + }) +} + +fn bundled_reasoning_catalog() -> &'static CatalogSnapshot { + static CATALOG: OnceLock = OnceLock::new(); + CATALOG.get_or_init(|| CatalogSnapshot { + // Source reasoning descriptors from the single bundled Models.dev + // snapshot (the same data #3385's catalog layer uses) rather than a + // hand-maintained per-row seed, so provider reasoning rows (GLM-5.2, + // etc.) cannot drift from the catalog and every bundled provider with + // reasoning facts is covered, not just GLM. + offerings: codewhale_config::catalog::bundled_catalog_offerings(), + }) +} + +fn codex_reasoning_controls() -> Vec { + [ + ReasoningEffort::Low, + ReasoningEffort::Medium, + ReasoningEffort::High, + ReasoningEffort::Max, + ] + .iter() + .map(|effort| { + effort + .display_label_for_provider(ApiProvider::OpenaiCodex) + .to_string() + }) + .collect() +} + +fn reasoning_controls_from_options(options: &[Value]) -> Vec { + let mut controls = Vec::new(); + for option in options { + collect_reasoning_controls(option, &mut controls); + } + controls +} + +fn collect_reasoning_controls(value: &Value, controls: &mut Vec) { + match value { + Value::String(text) => push_reasoning_control(controls, text), + Value::Array(items) => { + for item in items { + collect_reasoning_controls(item, controls); + } + } + Value::Object(map) => { + if let Some(values) = map.get("values") { + collect_reasoning_controls(values, controls); + } + } + _ => {} + } +} + +fn push_reasoning_control(controls: &mut Vec, value: &str) { + let normalized = value.trim(); + if normalized.is_empty() || controls.iter().any(|item| item == normalized) { + return; + } + controls.push(normalized.to_string()); +} + +fn selected_reasoning_control(provider: ApiProvider, config: &Config) -> Option { + let effort = ReasoningEffort::from_setting_for_provider(config.reasoning_effort()?, provider); + Some(effort.display_label_for_provider(provider).to_string()) +} + +fn configured_or_default_stream_visibility( + provider: ApiProvider, + config: &Config, + support: ProviderReasoningSupport, +) -> ProviderReasoningStreamVisibility { + if let Some(configured) = config + .provider_config_for(provider) + .and_then(|entry| entry.reasoning_stream_style.as_deref()) + && let Some(visibility) = parse_reasoning_stream_visibility(configured) + { + return visibility; + } + + match support { + ProviderReasoningSupport::Unsupported => ProviderReasoningStreamVisibility::NotExposed, + ProviderReasoningSupport::Unknown => ProviderReasoningStreamVisibility::Unknown, + ProviderReasoningSupport::Supported => default_reasoning_stream_visibility(provider), + } +} + +fn parse_reasoning_stream_visibility(value: &str) -> Option { + match value.trim().to_ascii_lowercase().replace('-', "_").as_str() { + "separate_field" | "separate" | "field" | "structured" | "structured_thinking" => { + Some(ProviderReasoningStreamVisibility::StructuredThinking) + } + "inline_tags" | "inline" | "think_tags" | "thinking_tags" => { + Some(ProviderReasoningStreamVisibility::InlineTags) + } + "summary" | "summary_only" => Some(ProviderReasoningStreamVisibility::SummaryOnly), + "none" | "text" | "disabled" | "off" | "not_exposed" => { + Some(ProviderReasoningStreamVisibility::NotExposed) + } + _ => None, + } +} + +fn default_reasoning_stream_visibility(provider: ApiProvider) -> ProviderReasoningStreamVisibility { + match provider { + ApiProvider::OpenaiCodex + | ApiProvider::Deepseek + | ApiProvider::DeepseekCN + | ApiProvider::NvidiaNim + | ApiProvider::Openrouter + | ApiProvider::XiaomiMimo + | ApiProvider::Novita + | ApiProvider::Fireworks + | ApiProvider::Siliconflow + | ApiProvider::SiliconflowCn + | ApiProvider::Volcengine + | ApiProvider::Arcee + | ApiProvider::Minimax + | ApiProvider::Sglang + | ApiProvider::Vllm + | ApiProvider::Zai + | ApiProvider::Moonshot => ProviderReasoningStreamVisibility::StructuredThinking, + _ => ProviderReasoningStreamVisibility::Unknown, + } +} + fn auth_status_for( provider: ApiProvider, has_key: bool, @@ -1084,6 +1459,202 @@ mod tests { ); } + #[test] + fn provider_dashboard_row_surfaces_glm_reasoning_controls() { + let config = Config { + reasoning_effort: Some("max".to_string()), + providers: Some(crate::config::ProvidersConfig { + zai: crate::config::ProviderConfig { + api_key: Some("zai-key".to_string()), + model: Some("GLM-5.2".to_string()), + ..Default::default() + }, + ..Default::default() + }), + ..Config::default() + }; + let row = ProviderDashboardRow::from_config(ApiProvider::Zai, ApiProvider::Zai, &config); + + assert_eq!(row.default_route.wire_model, "GLM-5.2"); + assert_eq!(row.reasoning.support, ProviderReasoningSupport::Supported); + assert_eq!( + row.reasoning.controls, + vec!["high".to_string(), "max".to_string()] + ); + assert_eq!( + row.reasoning.stream_visibility, + ProviderReasoningStreamVisibility::StructuredThinking + ); + assert_eq!(row.reasoning.selected_control.as_deref(), Some("max")); + assert!(row.compact_hint().contains("reasoning:high/max")); + assert!(row.compact_hint().contains("stream:structured")); + } + + #[test] + fn provider_dashboard_row_surfaces_codex_reasoning_scale() { + let config = Config { + reasoning_effort: Some("max".to_string()), + ..Config::default() + }; + let row = ProviderDashboardRow::from_config( + ApiProvider::OpenaiCodex, + ApiProvider::OpenaiCodex, + &config, + ); + + assert_eq!(row.reasoning.support, ProviderReasoningSupport::Supported); + assert_eq!( + row.reasoning.controls, + vec![ + "low".to_string(), + "medium".to_string(), + "high".to_string(), + "xhigh".to_string(), + ] + ); + assert_eq!( + row.reasoning.stream_visibility, + ProviderReasoningStreamVisibility::StructuredThinking + ); + assert_eq!(row.reasoning.selected_control.as_deref(), Some("xhigh")); + assert!( + row.compact_hint() + .contains("reasoning:low/medium/high/xhigh") + ); + } + + #[test] + fn provider_dashboard_row_surfaces_capability_and_metadata_badges() { + let config = Config { + providers: Some(crate::config::ProvidersConfig { + deepseek: crate::config::ProviderConfig { + api_key: Some("deepseek-key".to_string()), + ..Default::default() + }, + ..Default::default() + }), + ..Config::default() + }; + let row = ProviderDashboardRow::from_config( + ApiProvider::Deepseek, + ApiProvider::Deepseek, + &config, + ); + + // Metadata badges are projected from the resolved capability profile, + // never hardcoded per UI surface. + assert!(row.capabilities.context_window.is_some()); + assert!(row.capabilities.max_output.is_some()); + let hint = row.compact_hint(); + assert!(hint.contains("ctx:"), "metadata badge missing: {hint}"); + assert!(hint.contains("out:"), "metadata badge missing: {hint}"); + // Capability cluster present (tri-state; unknown renders `?`, never + // silently omitted). + for badge in ["tools:", "json:", "stream:", "cache:"] { + assert!( + hint.contains(badge), + "capability badge {badge} missing: {hint}" + ); + } + } + + #[test] + fn provider_dashboard_row_classifies_model_origin() { + // Default: no configured model override. + let config = Config::default(); + let row = ProviderDashboardRow::from_config( + ApiProvider::Deepseek, + ApiProvider::Deepseek, + &config, + ); + assert_eq!(row.model_origin, ProviderModelOrigin::Default); + assert!(row.compact_hint().contains("origin:default")); + + // Saved: a configured model override for the provider. + let config = Config { + providers: Some(crate::config::ProvidersConfig { + deepseek: crate::config::ProviderConfig { + api_key: Some("k".to_string()), + model: Some("deepseek-v4-flash".to_string()), + ..Default::default() + }, + ..Default::default() + }), + ..Config::default() + }; + let row = ProviderDashboardRow::from_config( + ApiProvider::Deepseek, + ApiProvider::Deepseek, + &config, + ); + assert_eq!(row.model_origin, ProviderModelOrigin::Saved); + assert!(row.compact_hint().contains("origin:saved")); + } + + #[test] + fn model_origin_classifier_covers_default_saved_custom() { + assert_eq!( + ProviderModelOrigin::for_provider(ApiProvider::Deepseek, false), + ProviderModelOrigin::Default + ); + assert_eq!( + ProviderModelOrigin::for_provider(ApiProvider::Deepseek, true), + ProviderModelOrigin::Saved + ); + assert_eq!( + ProviderModelOrigin::for_provider(ApiProvider::Custom, false), + ProviderModelOrigin::Custom + ); + // An explicit saved model still wins for a custom provider. + assert_eq!( + ProviderModelOrigin::for_provider(ApiProvider::Custom, true), + ProviderModelOrigin::Saved + ); + } + + #[test] + fn self_hosted_provider_row_marks_self_hosted_in_hint() { + let config = Config::default(); + let row = + ProviderDashboardRow::from_config(ApiProvider::Ollama, ApiProvider::Ollama, &config); + assert_eq!(row.auth_status, ProviderAuthStatus::Local); + assert!( + row.compact_hint().contains("(self-hosted)"), + "self-hosted hint missing: {}", + row.compact_hint() + ); + + let sglang = + ProviderDashboardRow::from_config(ApiProvider::Sglang, ApiProvider::Sglang, &config); + assert_eq!(sglang.auth_status, ProviderAuthStatus::Optional); + assert!( + sglang.compact_hint().contains("(self-hosted)"), + "self-hosted hint missing for SGLang: {}", + sglang.compact_hint() + ); + } + + #[test] + fn self_hosted_reasoning_visibility_covers_vllm() { + assert_eq!( + default_reasoning_stream_visibility(ApiProvider::Sglang), + ProviderReasoningStreamVisibility::StructuredThinking + ); + assert_eq!( + default_reasoning_stream_visibility(ApiProvider::Vllm), + ProviderReasoningStreamVisibility::StructuredThinking + ); + } + + #[test] + fn humanize_token_count_is_compact_and_marks_unknown() { + assert_eq!(humanize_token_count(None), "?"); + assert_eq!(humanize_token_count(Some(1_000_000)), "1M"); + assert_eq!(humanize_token_count(Some(1_500_000)), "1.5M"); + assert_eq!(humanize_token_count(Some(131_072)), "131K"); + assert_eq!(humanize_token_count(Some(512)), "512"); + } + #[test] fn provider_dashboard_row_uses_route_resolver_for_custom_openai_endpoint() { let config = Config {