From e77ba73c04ddb1bf470119a00460c3ad95aaf348 Mon Sep 17 00:00:00 2001 From: Hunter B Date: Tue, 23 Jun 2026 15:33:32 -0700 Subject: [PATCH 1/4] feat(tui): show provider reasoning readiness Add a ProviderReasoningSummary to /provider dashboard rows so the row projection can display reasoning support, accepted controls, stream visibility, and the selected configured control.\n\nSeed GLM reasoning controls in catalog-shaped provider+wire data and reuse the Codex effort labels so Codex displays its xhigh scale without model substring inference.\n\nAdd focused coverage for the GLM and Codex provider dashboard projections.\n\nRefs #3083 --- crates/tui/src/tui/provider_picker.rs | 328 +++++++++++++++++++++++++- 1 file changed, 327 insertions(+), 1 deletion(-) diff --git a/crates/tui/src/tui/provider_picker.rs b/crates/tui/src/tui/provider_picker.rs index 12bbfbb95f..b53ce85c84 100644 --- a/crates/tui/src/tui/provider_picker.rs +++ b/crates/tui/src/tui/provider_picker.rs @@ -28,11 +28,15 @@ use ratatui::{ use crate::config::{ApiProvider, Config, has_api_key_for, kimi_cli_credentials_present}; use crate::palette; +use crate::tui::app::ReasoningEffort; use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent}; +use codewhale_config::catalog::{CatalogOffering, CatalogSnapshot, CatalogSource}; 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 +64,7 @@ pub struct ProviderDashboardRow { pub available_model_count: usize, pub default_route: ProviderDefaultRoute, pub usage_meter: String, + pub reasoning: ProviderReasoningSummary, pub readiness: ProviderReadiness, pub maturity: ProviderMaturity, pub messages: Vec, @@ -130,6 +135,30 @@ impl ProviderMaturity { } } +#[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); @@ -165,6 +194,7 @@ impl ProviderDashboardRow { wire_model: "legacy alias".to_string(), }, usage_meter, + reasoning: ProviderReasoningSummary::unknown(provider, config), readiness: ProviderReadiness::Legacy, maturity: ProviderMaturity::for_provider(provider), messages: vec![ @@ -244,6 +274,7 @@ impl ProviderDashboardRow { } let readiness = readiness_for(provider, auth_status, route_ok); + let reasoning = ProviderReasoningSummary::for_route(provider, &default_route, config); Self { provider, @@ -257,6 +288,7 @@ impl ProviderDashboardRow { available_model_count, default_route, usage_meter: resolved_pricing, + reasoning, readiness, maturity: ProviderMaturity::for_provider(provider), messages, @@ -267,7 +299,7 @@ impl ProviderDashboardRow { fn compact_hint(&self) -> String { format!( - "{} | auth:{} | {} | {} | base:{} | route:{}{} | catalog:{}{}", + "{} | auth:{} | {} | {} | base:{} | route:{}{} | {} | catalog:{}{}", self.readiness.label(), self.auth_status.label(), self.usage_meter, @@ -275,6 +307,7 @@ impl ProviderDashboardRow { compact_base_url(&self.base_url), self.default_route.logical_model, route_wire_suffix(&self.default_route), + self.reasoning.label(), self.catalog_label(), // Only experimental integrations add a tag; supported ones stay // noise-free (#2984). @@ -294,6 +327,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 +429,159 @@ 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 { + // Keep these rows in catalog shape and exact provider+wire scope; the + // full Models.dev snapshot can replace this seed without changing the + // TUI projection contract. + offerings: vec![CatalogOffering { + provider: "zai".to_string(), + wire_model_id: "glm-5.2".to_string(), + canonical_model: Some("zhipuai/glm-5.2".to_string()), + endpoint_key: "chat".to_string(), + default_for_provider: true, + family: Some("glm".to_string()), + limit: None, + cost: None, + reasoning: Some(true), + reasoning_options: vec![serde_json::json!({ + "type": "effort", + "values": ["high", "max"], + })], + source: CatalogSource::Bundled, + }], + }) +} + +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::Zai + | ApiProvider::Moonshot => ProviderReasoningStreamVisibility::StructuredThinking, + _ => ProviderReasoningStreamVisibility::Unknown, + } +} + fn auth_status_for( provider: ApiProvider, has_key: bool, @@ -1084,6 +1346,70 @@ 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_uses_route_resolver_for_custom_openai_endpoint() { let config = Config { From a0d8ea832660f605986a5f6db583c5ebc64eea46 Mon Sep 17 00:00:00 2001 From: CodeWhale Agent Date: Wed, 24 Jun 2026 11:58:18 -0700 Subject: [PATCH 2/4] refactor(tui): source provider reasoning rows from bundled catalog (#3083) The reasoning readiness projection hand-seeded a single GLM-5.2 row in a OnceLock. main's bundled Models.dev snapshot already carries that row (reasoning + reasoning_options) plus every other provider's reasoning facts, so the seed was redundant and prone to drift. Source bundled_reasoning_catalog() from codewhale_config::catalog::bundled_catalog_offerings() instead. GLM-5.2 still resolves to high/max controls; all bundled providers with reasoning descriptors are now covered rather than just GLM. --- crates/tui/src/tui/provider_picker.rs | 27 +++++++-------------------- 1 file changed, 7 insertions(+), 20 deletions(-) diff --git a/crates/tui/src/tui/provider_picker.rs b/crates/tui/src/tui/provider_picker.rs index b53ce85c84..620f756fd1 100644 --- a/crates/tui/src/tui/provider_picker.rs +++ b/crates/tui/src/tui/provider_picker.rs @@ -30,7 +30,7 @@ use crate::config::{ApiProvider, Config, has_api_key_for, kimi_cli_credentials_p use crate::palette; use crate::tui::app::ReasoningEffort; use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent}; -use codewhale_config::catalog::{CatalogOffering, CatalogSnapshot, CatalogSource}; +use codewhale_config::catalog::{CatalogOffering, CatalogSnapshot}; use codewhale_config::provider::WireFormat; use codewhale_config::route::{ LogicalModelRef, PricingSku, RequestProtocol, RouteRequest, RouteResolver, bundled_offerings, @@ -448,25 +448,12 @@ fn reasoning_catalog_offering( fn bundled_reasoning_catalog() -> &'static CatalogSnapshot { static CATALOG: OnceLock = OnceLock::new(); CATALOG.get_or_init(|| CatalogSnapshot { - // Keep these rows in catalog shape and exact provider+wire scope; the - // full Models.dev snapshot can replace this seed without changing the - // TUI projection contract. - offerings: vec![CatalogOffering { - provider: "zai".to_string(), - wire_model_id: "glm-5.2".to_string(), - canonical_model: Some("zhipuai/glm-5.2".to_string()), - endpoint_key: "chat".to_string(), - default_for_provider: true, - family: Some("glm".to_string()), - limit: None, - cost: None, - reasoning: Some(true), - reasoning_options: vec![serde_json::json!({ - "type": "effort", - "values": ["high", "max"], - })], - source: CatalogSource::Bundled, - }], + // 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(), }) } From a7e677dcf784e44a9db3b524df925b68db706949 Mon Sep 17 00:00:00 2001 From: CodeWhale Agent Date: Wed, 24 Jun 2026 12:07:37 -0700 Subject: [PATCH 3/4] feat(tui): capability/metadata badges, model-origin + self-hosted hint (#3083) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the existing resolved_capability_profile() projection into the /provider dashboard rows so each row surfaces, with no wire-id substring inference: - metadata badges: context window + max output (humanized; `?` when unknown); - capability badges: tools, structured/JSON, streaming, cache — tri-state so unknown stays distinct from unsupported and is never silently dropped; - model-origin: default / saved override / custom pass-through; - a self-hosted hint next to the base URL for local runtimes. Badges are computed in the row projection (testable without rendering) and ordered after route/base so provider identity survives narrow-width truncation. The live "test connection" probe is deferred to a follow-up to honor the no-I/O-on-open constraint. --- crates/tui/src/tui/provider_picker.rs | 235 +++++++++++++++++++++++++- 1 file changed, 234 insertions(+), 1 deletion(-) diff --git a/crates/tui/src/tui/provider_picker.rs b/crates/tui/src/tui/provider_picker.rs index 620f756fd1..71d2bad008 100644 --- a/crates/tui/src/tui/provider_picker.rs +++ b/crates/tui/src/tui/provider_picker.rs @@ -27,6 +27,7 @@ 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}; @@ -65,6 +66,8 @@ pub struct ProviderDashboardRow { 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, @@ -135,6 +138,108 @@ 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, @@ -173,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); @@ -195,6 +302,8 @@ impl ProviderDashboardRow { }, usage_meter, reasoning: ProviderReasoningSummary::unknown(provider, config), + capabilities: ProviderCapabilityBadges::unknown(), + model_origin, readiness: ProviderReadiness::Legacy, maturity: ProviderMaturity::for_provider(provider), messages: vec![ @@ -275,6 +384,7 @@ 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, @@ -289,6 +399,8 @@ impl ProviderDashboardRow { default_route, usage_meter: resolved_pricing, reasoning, + capabilities, + model_origin, readiness, maturity: ProviderMaturity::for_provider(provider), messages, @@ -298,15 +410,25 @@ 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 self.auth_status == ProviderAuthStatus::Local { + " (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 @@ -1397,6 +1519,117 @@ mod tests { ); } + #[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() + ); + } + + #[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 { From dad66951a3c64a5a28d55178091cb18b0cba1a1c Mon Sep 17 00:00:00 2001 From: CodeWhale Agent Date: Wed, 24 Jun 2026 13:03:16 -0700 Subject: [PATCH 4/4] fix(tui): mark optional local providers self-hosted Address provider dashboard review feedback. - Treat Optional auth status as self-hosted in compact provider hints, covering SGLang and vLLM local runtimes. - Classify vLLM reasoning stream visibility consistently with SGLang. - Add regression coverage for both cases. Verification: - cargo fmt --all - cargo test -p codewhale-tui --bin codewhale-tui --locked provider_dashboard - cargo test -p codewhale-tui --bin codewhale-tui --locked self_hosted --- crates/tui/src/tui/provider_picker.rs | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/crates/tui/src/tui/provider_picker.rs b/crates/tui/src/tui/provider_picker.rs index 71d2bad008..40a3e2590a 100644 --- a/crates/tui/src/tui/provider_picker.rs +++ b/crates/tui/src/tui/provider_picker.rs @@ -412,7 +412,10 @@ 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 self.auth_status == ProviderAuthStatus::Local { + let self_hosted = if matches!( + self.auth_status, + ProviderAuthStatus::Local | ProviderAuthStatus::Optional + ) { " (self-hosted)" } else { "" @@ -685,6 +688,7 @@ fn default_reasoning_stream_visibility(provider: ApiProvider) -> ProviderReasoni | ApiProvider::Arcee | ApiProvider::Minimax | ApiProvider::Sglang + | ApiProvider::Vllm | ApiProvider::Zai | ApiProvider::Moonshot => ProviderReasoningStreamVisibility::StructuredThinking, _ => ProviderReasoningStreamVisibility::Unknown, @@ -1619,6 +1623,27 @@ mod tests { "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]