refactor(tui): move reasoning preferences out of UI app module

Move ReasoningEffort and EffectiveReasoningEffort (with all impls) from
tui::app::types into the crate-level reasoning_preference module so engine,
client routing, settings, and tools resolve effort without depending on
presentation types. Repoint every consumer and drop the temporary re-export
shim in tui/app/types.rs; tui::app no longer re-exports either type.

Verification (real output):
- cargo check -p codewhale-tui --all-targets --locked: pass, 0 warnings
- cargo test -p codewhale-tui --lib --locked reasoning:
  test result: ok. 229 passed; 0 failed; 0 ignored; 0 measured;
  12368 filtered out
This commit is contained in:
CodeWhale Bot committed 2026-09-09 17:48:03 -07:00
1 parent 798d3d6647
commit 76f8d0044d
37 files changed
+588 -574

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@
//! [`select`] before each turn-level request to pick the actual tier
//! based on the current message.
use crate::tui::app::ReasoningEffort;
use crate::reasoning_preference::ReasoningEffort;
/// Choose a concrete `ReasoningEffort` tier for the next API request.
///
+2 -2
View File
@@ -917,8 +917,8 @@ fn tool_to_responses_function(tool: &Tool) -> Value {
}
fn codex_responses_reasoning_effort(raw: &str) -> Option<&'static str> {
crate::tui::app::ReasoningEffort::parse_strict(raw)
.unwrap_or(crate::tui::app::ReasoningEffort::Medium)
crate::reasoning_preference::ReasoningEffort::parse_strict(raw)
.unwrap_or(crate::reasoning_preference::ReasoningEffort::Medium)
.api_value_for_provider(ApiProvider::OpenaiCodex)
}
+1 -1
View File
@@ -540,7 +540,7 @@ fn codex_reasoning_effort_uses_responses_labels() {
#[tokio::test]
async fn codex_selected_effort_reaches_preview_wire_and_restored_receipt_unchanged() {
use crate::tui::app::{EffectiveReasoningEffort, ReasoningEffort};
use crate::reasoning_preference::{EffectiveReasoningEffort, ReasoningEffort};
use crate::work_graph::WorkActivityEvent;
let server = MockServer::start().await;
+2 -1
View File
@@ -70,7 +70,8 @@ use crate::dependencies::ExternalTool as _;
use crate::localization::{MessageId, tr};
use crate::network_policy::NetworkPolicy;
use crate::pricing::CostCurrency;
use crate::tui::app::{App, ReasoningEffort};
use crate::reasoning_preference::ReasoningEffort;
use crate::tui::app::App;
use crate::tui::history::HistoryCell;
// ---------------------------------------------------------------------------
@@ -17,11 +17,9 @@ use crate::config_persistence::{
persist_tui_integer_key, persist_unset_root_key,
};
use crate::localization::{MessageId, resolve_locale, tr};
use crate::reasoning_preference::ReasoningEffort;
use crate::settings::Settings;
use crate::tui::app::{
App, AppAction, AppMode, OnboardingState, ReasoningEffort, ScreenMode, SettingSelection,
VimMode,
};
use crate::tui::app::{App, AppAction, AppMode, OnboardingState, ScreenMode, SettingSelection, VimMode};
use anyhow::Result;
use codewhale_execpolicy::ApprovalMode;
use std::path::{Path, PathBuf};
+1 -1
View File
@@ -9,7 +9,7 @@ use crate::config::{
};
use crate::localization::{Locale, MessageId, tr};
#[cfg(test)]
use crate::tui::app::ReasoningEffort;
use crate::reasoning_preference::ReasoningEffort;
use crate::tui::app::{App, AppAction, AppMode};
use crate::tui::views::{HelpView, ModalKind, SubAgentsView, subagent_view_agents};
@@ -8,7 +8,8 @@
use crate::commands::{CommandResult, traits::CommandInfo};
use crate::localization::{MessageId, tr};
use crate::tui::app::{App, ReasoningEffort};
use crate::reasoning_preference::ReasoningEffort;
use crate::tui::app::App;
pub const EFFORT_INFO: CommandInfo = CommandInfo {
name: "effort",
@@ -752,7 +752,7 @@ mod tests {
),
crate::config::DEEPSEEK_ALIAS_REPLACEMENT
);
app.reasoning_effort = crate::tui::app::ReasoningEffort::Max;
app.reasoning_effort = crate::reasoning_preference::ReasoningEffort::Max;
app.reasoning_effort_preference = None;
app.apply_provider_switch_reasoning_effort(
provider,
@@ -762,9 +762,9 @@ mod tests {
assert_eq!(
app.reasoning_effort,
if alias == "deepseek-chat" {
crate::tui::app::ReasoningEffort::Off
crate::reasoning_preference::ReasoningEffort::Off
} else {
crate::tui::app::ReasoningEffort::High
crate::reasoning_preference::ReasoningEffort::High
},
"{provider:?} {alias}"
);
@@ -805,7 +805,7 @@ mod tests {
),
"deepseek-reasoner"
);
app.reasoning_effort = crate::tui::app::ReasoningEffort::Max;
app.reasoning_effort = crate::reasoning_preference::ReasoningEffort::Max;
app.reasoning_effort_preference = None;
app.apply_provider_switch_reasoning_effort(
provider,
@@ -814,11 +814,11 @@ mod tests {
);
assert_eq!(
app.reasoning_effort,
crate::tui::app::ReasoningEffort::Max,
crate::reasoning_preference::ReasoningEffort::Max,
"custom endpoint owns alias semantics"
);
app.reasoning_effort_preference = Some(crate::tui::app::ReasoningEffort::Max);
app.reasoning_effort_preference = Some(crate::reasoning_preference::ReasoningEffort::Max);
app.apply_provider_switch_reasoning_effort(
provider,
crate::config::DEFAULT_DEEPSEEK_BASE_URL,
@@ -826,7 +826,7 @@ mod tests {
);
assert_eq!(
app.reasoning_effort,
crate::tui::app::ReasoningEffort::Max,
crate::reasoning_preference::ReasoningEffort::Max,
"explicit effort must beat compatibility inference"
);
}
@@ -566,7 +566,7 @@ fn cache_inspect_json_keys_auto_replay_to_the_last_concrete_route() {
let mut app = create_test_app();
app.model = "auto".to_string();
app.auto_model = true;
app.reasoning_effort = crate::tui::app::ReasoningEffort::Off;
app.reasoning_effort = crate::reasoning_preference::ReasoningEffort::Off;
app.last_effective_provider = Some(crate::config::ApiProvider::OpenaiCodex);
app.last_effective_provider_identity =
Some(crate::config::ApiProvider::OpenaiCodex.as_str().to_string());
@@ -11,9 +11,10 @@ use tempfile::TempDir;
use crate::commands::CommandResult;
use crate::config::Config;
use crate::models::Role;
use crate::reasoning_preference::ReasoningEffort;
use crate::session_manager::create_saved_session_with_id_and_mode;
use crate::test_support::EnvVarGuard;
use crate::tui::app::{App, AppAction, AppMode, ReasoningEffort, TuiOptions, TurnCacheRecord};
use crate::tui::app::{App, AppAction, AppMode, TuiOptions, TurnCacheRecord};
use crate::tui::history::HistoryCell;
fn dispatch_lifecycle(app: &mut App, name: &str, arg: Option<&str>) -> CommandResult {
@@ -123,7 +124,7 @@ fn save_preserves_latest_auto_route_receipt() {
app.last_effective_model = Some(crate::config::ZAI_GLM_5_TURBO_MODEL.to_string());
app.last_auto_route_receipt = Some(receipt.clone());
app.last_effective_reasoning_effort =
Some(crate::tui::app::EffectiveReasoningEffort::ThinkingEnabledGranularityUnavailable);
Some(crate::reasoning_preference::EffectiveReasoningEffort::ThinkingEnabledGranularityUnavailable);
let result = save(&mut app, Some(save_path.to_str().unwrap()));
@@ -635,7 +636,7 @@ fn load_auto_model_session_defers_model_restore_to_event_loop() {
saved_app.set_model_selection("auto".to_string());
saved_app.last_effective_model = Some("deepseek-v4-flash".to_string());
saved_app.last_effective_reasoning_effort = Some(
crate::tui::app::EffectiveReasoningEffort::Tier(ReasoningEffort::Low),
crate::reasoning_preference::EffectiveReasoningEffort::Tier(ReasoningEffort::Low),
);
let save_path = tmpdir.path().join("auto_model.json");
save(&mut saved_app, Some(save_path.to_str().unwrap()));
+9 -9
View File
@@ -761,9 +761,9 @@ async fn plan_for(
model,
auto_model,
if auto_model {
crate::tui::app::ReasoningEffort::Auto
crate::reasoning_preference::ReasoningEffort::Auto
} else {
crate::tui::app::ReasoningEffort::High
crate::reasoning_preference::ReasoningEffort::High
},
prompt,
)
@@ -779,7 +779,7 @@ async fn plan_with_reasoning(
provider: ApiProvider,
model: &str,
auto_model: bool,
reasoning_effort: crate::tui::app::ReasoningEffort,
reasoning_effort: crate::reasoning_preference::ReasoningEffort,
prompt: &str,
) -> crate::turn_route_plan::PlannedTurnRoute {
crate::turn_route_plan::plan_turn_route(crate::turn_route_plan::TurnRoutePlanRequest {
@@ -1334,7 +1334,7 @@ struct MatrixRoute {
provider_key: &'static str,
base_url: &'static str,
model: &'static str,
requested_reasoning: crate::tui::app::ReasoningEffort,
requested_reasoning: crate::reasoning_preference::ReasoningEffort,
requested_reasoning_label: &'static str,
/// Reasoning-control keys the manifest must report, in receipt order.
expect_control_keys: &'static [&'static str],
@@ -1354,7 +1354,7 @@ fn glm_5_2_zai_coding() -> MatrixRoute {
provider_key: "zai",
base_url: crate::config::DEFAULT_ZAI_BASE_URL,
model: crate::config::ZAI_GLM_5_2_MODEL,
requested_reasoning: crate::tui::app::ReasoningEffort::High,
requested_reasoning: crate::reasoning_preference::ReasoningEffort::High,
requested_reasoning_label: "high",
expect_control_keys: &["reasoning_effort", "thinking"],
expect_wire_effort: Some("high"),
@@ -1371,7 +1371,7 @@ fn glm_5_turbo_zai() -> MatrixRoute {
provider_key: "zai",
base_url: crate::config::DEFAULT_ZAI_BASE_URL,
model: crate::config::ZAI_GLM_5_TURBO_MODEL,
requested_reasoning: crate::tui::app::ReasoningEffort::High,
requested_reasoning: crate::reasoning_preference::ReasoningEffort::High,
requested_reasoning_label: "high",
// No invented granularity: the toggle ships, the tier does not.
expect_control_keys: &["thinking"],
@@ -1390,7 +1390,7 @@ fn kimi_k3_moonshot_direct() -> MatrixRoute {
base_url: crate::config::DEFAULT_MOONSHOT_BASE_URL,
model: crate::config::MOONSHOT_KIMI_K3_MODEL,
// The visible normalization: `off` is not a tier this route has.
requested_reasoning: crate::tui::app::ReasoningEffort::Off,
requested_reasoning: crate::reasoning_preference::ReasoningEffort::Off,
requested_reasoning_label: "off",
expect_control_keys: &["reasoning_effort"],
expect_wire_effort: Some("low"),
@@ -1407,7 +1407,7 @@ fn k3_kimi_code() -> MatrixRoute {
provider_key: "moonshot",
base_url: crate::config::DEFAULT_KIMI_CODE_BASE_URL,
model: crate::config::KIMI_CODE_K3_MODEL,
requested_reasoning: crate::tui::app::ReasoningEffort::Off,
requested_reasoning: crate::reasoning_preference::ReasoningEffort::Off,
requested_reasoning_label: "off",
expect_control_keys: &["thinking"],
expect_wire_effort: Some("low"),
@@ -1424,7 +1424,7 @@ fn minimax_m3() -> MatrixRoute {
provider_key: "minimax",
base_url: crate::config::DEFAULT_MINIMAX_BASE_URL,
model: crate::config::DEFAULT_MINIMAX_MODEL,
requested_reasoning: crate::tui::app::ReasoningEffort::High,
requested_reasoning: crate::reasoning_preference::ReasoningEffort::High,
requested_reasoning_label: "high",
expect_control_keys: &["thinking", "reasoning_split"],
expect_wire_effort: None,
+1 -1
View File
@@ -55,7 +55,7 @@ use super::role::{
use crate::config::{ApiProvider, Config};
use crate::llm_client::LlmClient;
use crate::models::Role;
use crate::tui::app::ReasoningEffort;
use crate::reasoning_preference::ReasoningEffort;
/// Where exact Fleet definitions and Reasoning Router profiles are looked up,
/// labelled so an identity can be qualified (`workspace/glm-pair`) instead of
+1 -1
View File
@@ -8,7 +8,7 @@ use std::path::{Path, PathBuf};
use anyhow::{Context, Result, anyhow, bail};
use serde::Deserialize;
use crate::tui::app::ReasoningEffort;
use crate::reasoning_preference::ReasoningEffort;
#[allow(unused_imports)]
pub use codewhale_config::{
+17 -16
View File
@@ -98,6 +98,7 @@ mod provider_catalog_live;
mod provider_lake;
mod provider_readiness;
mod purge;
pub mod reasoning_preference;
mod regex_cache;
mod remote_control;
mod remote_setup;
@@ -11198,7 +11199,7 @@ async fn run_interactive_with_notice(
struct CliAutoRoute {
provider: crate::config::ApiProvider,
model: String,
reasoning_effort: Option<crate::tui::app::ReasoningEffort>,
reasoning_effort: Option<crate::reasoning_preference::ReasoningEffort>,
/// Whether the runtime should continue resolving reasoning per prompt.
///
/// This is independent from `auto_model`: an Auto model can carry a fixed
@@ -11210,7 +11211,7 @@ struct CliAutoRoute {
fn cli_reasoning_effort_value(
config: &Config,
model: &str,
effort: crate::tui::app::ReasoningEffort,
effort: crate::reasoning_preference::ReasoningEffort,
) -> Option<String> {
effort
.api_value_for_route(config.api_provider(), &config.deepseek_base_url(), model)
@@ -11220,10 +11221,10 @@ fn cli_reasoning_effort_value(
fn cli_reasoning_effort_value_for_prompt(
config: &Config,
model: &str,
effort: crate::tui::app::ReasoningEffort,
effort: crate::reasoning_preference::ReasoningEffort,
prompt: &str,
) -> Option<String> {
let resolved = if effort == crate::tui::app::ReasoningEffort::Auto {
let resolved = if effort == crate::reasoning_preference::ReasoningEffort::Auto {
crate::auto_reasoning::select(false, prompt)
} else {
effort
@@ -11242,7 +11243,7 @@ fn normalize_cli_reasoning_effort(value: &str) -> Result<Option<String>> {
) {
return Ok(None);
}
crate::tui::app::ReasoningEffort::parse_strict(trimmed)
crate::reasoning_preference::ReasoningEffort::parse_strict(trimmed)
.map(|effort| Some(effort.as_setting().to_string()))
.map_err(anyhow::Error::msg)
}
@@ -11272,7 +11273,7 @@ async fn resolve_cli_auto_route(
let preference = config
.reasoning_effort()
.filter(|_| config.reasoning_effort_is_explicit())
.map(crate::tui::app::ReasoningEffort::from_setting);
.map(crate::reasoning_preference::ReasoningEffort::from_setting);
let (reasoning_effort, auto_controls_reasoning) =
model_routing::resolve_auto_model_reasoning(preference, selection.reasoning_effort);
Ok(CliAutoRoute {
@@ -11287,7 +11288,7 @@ async fn resolve_cli_auto_route(
{
let auto_controls_reasoning = matches!(
selection.reasoning_effort,
Some(crate::tui::app::ReasoningEffort::Auto)
Some(crate::reasoning_preference::ReasoningEffort::Auto)
);
return Ok(CliAutoRoute {
provider: selection.provider,
@@ -11321,13 +11322,13 @@ async fn resolve_cli_auto_route(
// 30+ second SSE idle timeouts on trivial prompts.
let reasoning_effort = config
.reasoning_effort()
.map(crate::tui::app::ReasoningEffort::from_setting);
.map(crate::reasoning_preference::ReasoningEffort::from_setting);
Ok(CliAutoRoute {
provider: config.api_provider(),
model: model.to_string(),
auto_controls_reasoning: matches!(
reasoning_effort,
Some(crate::tui::app::ReasoningEffort::Auto)
Some(crate::reasoning_preference::ReasoningEffort::Auto)
),
reasoning_effort,
auto_model: false,
@@ -11344,13 +11345,13 @@ async fn resolve_cli_exec_route(
if force_configured_route && !model.trim().eq_ignore_ascii_case("auto") {
let reasoning_effort = config
.reasoning_effort()
.map(crate::tui::app::ReasoningEffort::from_setting);
.map(crate::reasoning_preference::ReasoningEffort::from_setting);
return Ok(CliAutoRoute {
provider: config.api_provider(),
model: model.to_string(),
auto_controls_reasoning: matches!(
reasoning_effort,
Some(crate::tui::app::ReasoningEffort::Auto)
Some(crate::reasoning_preference::ReasoningEffort::Auto)
),
reasoning_effort,
auto_model: false,
@@ -15131,7 +15132,7 @@ reasoning = "high"
assert!(route.auto_model);
assert_eq!(
route.reasoning_effort,
Some(crate::tui::app::ReasoningEffort::Low)
Some(crate::reasoning_preference::ReasoningEffort::Low)
);
assert!(
!route.auto_controls_reasoning,
@@ -16784,7 +16785,7 @@ api_key = "test-only-key"
cli_reasoning_effort_value_for_prompt(
&config,
crate::config::KIMI_CODE_K3_MODEL,
crate::tui::app::ReasoningEffort::Auto,
crate::reasoning_preference::ReasoningEffort::Auto,
prompt,
)
.as_deref(),
@@ -16797,7 +16798,7 @@ api_key = "test-only-key"
cli_reasoning_effort_value_for_prompt(
&config,
crate::config::KIMI_CODE_K3_MODEL,
crate::tui::app::ReasoningEffort::Off,
crate::reasoning_preference::ReasoningEffort::Off,
"debug must not override an explicit effort",
)
.as_deref(),
@@ -16808,7 +16809,7 @@ api_key = "test-only-key"
#[test]
fn cli_route_tracks_auto_reasoning_independently_from_auto_model() {
use crate::tui::app::ReasoningEffort;
use crate::reasoning_preference::ReasoningEffort;
let fixed_model_auto_reasoning = CliAutoRoute {
provider: crate::config::ApiProvider::Deepseek,
@@ -16861,7 +16862,7 @@ api_key = "test-only-key"
let resolved = cli_reasoning_effort_value_for_prompt(
&config,
crate::config::ZAI_GLM_5_2_MODEL,
crate::tui::app::ReasoningEffort::Auto,
crate::reasoning_preference::ReasoningEffort::Auto,
"debug this failing integration test",
)
.expect("Auto must resolve to a concrete tier");
+1 -1
View File
@@ -17,7 +17,7 @@ use crate::llm_client::LlmClient;
use crate::model_inventory::ModelInventory;
use crate::models::Role;
use crate::models::{ContentBlock, Message, MessageRequest, MessageResponse, SystemPrompt};
use crate::tui::app::ReasoningEffort;
use crate::reasoning_preference::ReasoningEffort;
/// Big/cheap model pair the auto-router may choose between for the active
/// provider (#3018).
+476
View File
@@ -0,0 +1,476 @@
//! Reasoning preferences and effective tiers (#5316).
//!
//! Separated from TUI app state so engine, client routing, settings, and
//! tools can resolve and normalize reasoning effort without depending on
//! presentation types.
use crate::config::ApiProvider;
use crate::work_graph::ReasoningEffortTier;
/// Reasoning-effort tier, mirrored across DeepSeek and Codex effort pickers.
///
/// The config file accepts every supported string value for forward-compat with
/// providers that expose the full spectrum; DeepSeek currently collapses
/// `Low`/`Medium` → `high`. OpenAI Codex normalizes inherited DeepSeek-only
/// `Off` to `Low` and keeps `XHigh`, `Max`, and `Ultra` distinct at the
/// provider boundary. The default keyboard cycler walks the three DeepSeek-distinct
/// tiers: `Off` → `High` → `Max` → `Off`; provider-aware callers should use
/// [`ReasoningEffort::cycle_next_in`] with the route's effort list. Auto
/// routing has no concrete provider yet, so
/// [`ReasoningEffort::cycle_next_for_auto_model`] retains the full
/// provider-neutral preference vocabulary until dispatch.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum ReasoningEffort {
Off,
Minimal,
Low,
Medium,
High,
XHigh,
Ultra,
Auto,
#[default]
Max,
}
/// Provider-effective reasoning state used by durable receipts and visible
/// requested-to-effective labels.
///
/// Some routes, notably first-party GLM-5-Turbo, support a thinking toggle but
/// publish no effort tiers. Keeping that state distinct prevents a requested
/// `max` from being displayed or persisted as an effective `max` claim.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EffectiveReasoningEffort {
Tier(ReasoningEffort),
ThinkingEnabledGranularityUnavailable,
Unavailable,
}
impl EffectiveReasoningEffort {
/// Reconstruct a safe request tier for cache replay and inspection.
///
/// Routes with an enabled-but-untiered receipt collapse every non-Off
/// request to the same wire toggle, so High is the canonical value that
/// keeps reasoning enabled without claiming a granular effective tier.
#[must_use]
pub const fn request_tier_for_replay(self) -> Option<ReasoningEffort> {
match self {
Self::Tier(tier) => Some(tier),
Self::ThinkingEnabledGranularityUnavailable => Some(ReasoningEffort::High),
Self::Unavailable => None,
}
}
}
impl From<EffectiveReasoningEffort> for ReasoningEffortTier {
fn from(value: EffectiveReasoningEffort) -> Self {
match value {
EffectiveReasoningEffort::Tier(tier) => tier.into(),
EffectiveReasoningEffort::ThinkingEnabledGranularityUnavailable => {
Self::ThinkingEnabledGranularityUnavailable
}
EffectiveReasoningEffort::Unavailable => Self::Unavailable,
}
}
}
impl From<ReasoningEffortTier> for EffectiveReasoningEffort {
fn from(value: ReasoningEffortTier) -> Self {
match value {
ReasoningEffortTier::Off => Self::Tier(ReasoningEffort::Off),
ReasoningEffortTier::Minimal => Self::Tier(ReasoningEffort::Minimal),
ReasoningEffortTier::Low => Self::Tier(ReasoningEffort::Low),
ReasoningEffortTier::Medium => Self::Tier(ReasoningEffort::Medium),
ReasoningEffortTier::High => Self::Tier(ReasoningEffort::High),
ReasoningEffortTier::XHigh => Self::Tier(ReasoningEffort::XHigh),
ReasoningEffortTier::Ultra => Self::Tier(ReasoningEffort::Ultra),
ReasoningEffortTier::Auto => Self::Tier(ReasoningEffort::Auto),
ReasoningEffortTier::Max => Self::Tier(ReasoningEffort::Max),
ReasoningEffortTier::ThinkingEnabledGranularityUnavailable => {
Self::ThinkingEnabledGranularityUnavailable
}
ReasoningEffortTier::Unavailable => Self::Unavailable,
}
}
}
impl From<ReasoningEffort> for ReasoningEffortTier {
fn from(value: ReasoningEffort) -> Self {
match value {
ReasoningEffort::Off => Self::Off,
ReasoningEffort::Minimal => Self::Minimal,
ReasoningEffort::Low => Self::Low,
ReasoningEffort::Medium => Self::Medium,
ReasoningEffort::High => Self::High,
ReasoningEffort::XHigh => Self::XHigh,
ReasoningEffort::Ultra => Self::Ultra,
ReasoningEffort::Auto => Self::Auto,
ReasoningEffort::Max => Self::Max,
}
}
}
impl ReasoningEffort {
/// Parse an operator-supplied effort value.
///
/// This is deliberately the one canonical spelling table for every
/// human-facing route. Callers that read an old persisted config may use
/// [`Self::from_setting`] for its compatibility fallback, but a new CLI,
/// settings, or tool input must reject an unknown value instead of quietly
/// turning it into `max`.
pub fn parse_strict(value: &str) -> Result<Self, String> {
let trimmed = value.trim();
match trimmed.to_ascii_lowercase().as_str() {
"off" | "disabled" | "none" | "false" => Ok(Self::Off),
"low" | "minimum" | "minimal" | "light" => Ok(Self::Low),
"medium" | "mid" => Ok(Self::Medium),
"high" => Ok(Self::High),
"xhigh" => Ok(Self::XHigh),
"auto" | "automatic" => Ok(Self::Auto),
"ultra" | "ultracode" => Ok(Self::Ultra),
"max" | "maximum" => Ok(Self::Max),
_ => Err(format!(
"Unrecognized reasoning effort {trimmed:?}. Expected: auto, off, low, medium, high, xhigh, max, or ultra."
)),
}
}
/// Parse a persisted config-file string into an effort tier. Unknown
/// legacy values fall back to the default (`Max`) so an old malformed
/// settings file never prevents startup. New user input should use
/// [`Self::parse_strict`] instead.
#[must_use]
pub fn from_setting(value: &str) -> Self {
Self::parse_strict(value).unwrap_or_default()
}
#[must_use]
pub fn from_setting_for_provider(value: &str, provider: ApiProvider) -> Self {
Self::from_setting(value).normalize_for_provider(provider)
}
/// Canonical lowercase label used for config storage and UI hints.
#[must_use]
pub fn as_setting(self) -> &'static str {
match self {
Self::Off => "off",
Self::Minimal => "minimal",
Self::Low => "low",
Self::Medium => "medium",
Self::High => "high",
Self::XHigh => "xhigh",
Self::Ultra => "ultra",
Self::Auto => "auto",
Self::Max => "max",
}
}
/// Short label for the header chip.
#[must_use]
pub fn short_label(self) -> &'static str {
match self {
Self::Off => "off",
Self::Minimal => "minimal",
Self::Low => "low",
Self::Medium => "med",
Self::High => "high",
Self::XHigh => "xhigh",
Self::Ultra => "ultra",
Self::Auto => "auto",
Self::Max => "max",
}
}
/// Provider-facing label for user-visible surfaces.
#[must_use]
pub fn display_label_for_provider(self, provider: ApiProvider) -> &'static str {
match (provider, self.normalize_for_provider(provider)) {
(ApiProvider::OpenaiCodex, Self::Minimal) => "low",
(ApiProvider::OpenaiCodex, Self::Low) => "low",
(ApiProvider::OpenaiCodex, Self::Medium) => "medium",
(ApiProvider::OpenaiCodex, Self::High) => "high",
// `xhigh`, `max` and `ultra` are three distinct rungs the Codex
// roster publishes per model; collapsing them onto "xhigh" dates
// from when xhigh was the ceiling and made the top tiers
// unreachable and indistinguishable.
(ApiProvider::OpenaiCodex, Self::XHigh) => "xhigh",
(ApiProvider::OpenaiCodex, Self::Ultra) => "ultra",
(ApiProvider::OpenaiCodex, Self::Max) => "max",
(ApiProvider::Xai, Self::XHigh) => "xhigh",
(_, effort) => effort.short_label(),
}
}
/// Value forwarded to the engine/client. `None` means "provider default"
/// (for `Off` we still emit `"off"` so the client can inject
/// `thinking = {"type": "disabled"}`).
#[must_use]
pub fn api_value(self) -> Option<&'static str> {
Some(self.as_setting())
}
#[must_use]
pub fn normalize_for_provider(self, provider: ApiProvider) -> Self {
if provider != ApiProvider::OpenaiCodex {
return self;
}
match self {
Self::Off => Self::Low,
Self::Auto => Self::Medium,
other => other,
}
}
/// Resolve an effort against the exact provider route that will receive
/// the request. Both K3 routes are always-thinking, so `off` becomes the
/// lowest supported tier. The Kimi Code membership route otherwise keeps
/// its low/high/max mapping; direct Moonshot K3 additionally maps `medium`
/// to `high`. First-party DeepSeek routes keep `low` (the wire documents
/// low/high/max) while rounding `medium` up to `high`. Models that publish
/// a Models.dev `reasoning_options` effort list keep that vocabulary
/// instead of the historic Low/Medium collapse. Generic Moonshot and
/// every other non-Codex route retain the historic high coercion.
/// This intentionally does not change [`Self::normalize_for_provider`],
/// whose generic wire semantics are used by older callers that do not yet
/// have a route receipt.
#[must_use]
pub fn normalize_for_route(
self,
provider: ApiProvider,
base_url: &str,
wire_model: &str,
) -> Self {
let normalized = self.normalize_for_provider(provider);
if crate::config::is_exact_kimi_code_k3_route(provider, base_url, wire_model) {
return match normalized {
Self::Off => Self::Low,
other => other,
};
}
if crate::config::is_exact_direct_moonshot_k3_route(provider, base_url, wire_model) {
return match normalized {
Self::Off => Self::Low,
Self::Medium => Self::High,
other => other,
};
}
if provider == ApiProvider::OpenaiCodex {
return normalized;
}
// First-party DeepSeek routes document `reasoning_effort` low/high/max
// on the wire (no medium), so `low` is a real, cheaper tier there and
// must reach the wire as low; `medium` rounds up to high because the
// dialect has no such value (#52).
if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {
return match normalized {
Self::Low => Self::Low,
Self::Medium => Self::High,
other => other,
};
}
// Ollama's current OpenAI-compatible Chat Completions contract
// documents the complete none/low/medium/high/max ladder. Keep every
// real tier distinct for normal turns; only Codewhale-only synonyms
// are folded onto the nearest documented spelling.
if provider == ApiProvider::OllamaCloud {
return match normalized {
Self::Minimal => Self::Low,
Self::XHigh | Self::Ultra => Self::Max,
other => other,
};
}
if let Some(values) = Self::catalog_effort_values(provider, wire_model) {
return Self::clamp_to_catalog_efforts(normalized, provider, wire_model, &values);
}
match normalized {
Self::Low | Self::Medium => Self::High,
other => other,
}
}
pub fn catalog_default(provider: ApiProvider, wire_model: &str) -> Option<Self> {
let offering = crate::provider_lake::catalog_offering_for_model(provider, wire_model)?;
offering.reasoning_options.iter().find_map(|option| {
option
.get("type")
.and_then(|value| value.as_str())
.filter(|kind| kind.eq_ignore_ascii_case("effort"))?;
option
.get("default")
.and_then(|value| value.as_str())
.and_then(Self::from_catalog_token)
})
}
pub fn catalog_effort_values(provider: ApiProvider, wire_model: &str) -> Option<Vec<Self>> {
let offering = crate::provider_lake::catalog_offering_for_model(provider, wire_model)?;
let mut efforts = Vec::new();
for option in &offering.reasoning_options {
if !option
.get("type")
.and_then(|value| value.as_str())
.is_some_and(|kind| kind.eq_ignore_ascii_case("effort"))
{
continue;
}
let Some(values) = option.get("values").and_then(|value| value.as_array()) else {
continue;
};
for value in values {
if let Some(effort) = value.as_str().and_then(Self::from_catalog_token)
&& !efforts.contains(&effort)
{
efforts.push(effort);
}
}
}
(!efforts.is_empty()).then_some(efforts)
}
pub fn from_catalog_token(raw: &str) -> Option<Self> {
match raw.trim().to_ascii_lowercase().as_str() {
"off" | "disabled" | "none" | "false" => Some(Self::Off),
"minimal" | "minimum" => Some(Self::Minimal),
"low" | "light" => Some(Self::Low),
"medium" | "mid" => Some(Self::Medium),
"high" => Some(Self::High),
"xhigh" => Some(Self::XHigh),
"ultra" | "ultracode" => Some(Self::Ultra),
"max" | "maximum" => Some(Self::Max),
"auto" | "automatic" | "adaptive" => Some(Self::Auto),
_ => None,
}
}
fn clamp_to_catalog_efforts(
normalized: Self,
provider: ApiProvider,
wire_model: &str,
values: &[Self],
) -> Self {
if matches!(normalized, Self::Auto) || values.contains(&normalized) {
return normalized;
}
let aliased = match normalized {
Self::Minimal if values.contains(&Self::Low) => Self::Low,
Self::Max | Self::Ultra if values.contains(&Self::XHigh) => Self::XHigh,
Self::Off => Self::catalog_default(provider, wire_model).unwrap_or(Self::High),
other => other,
};
if values.contains(&aliased) {
aliased
} else {
Self::catalog_default(provider, wire_model).unwrap_or(Self::High)
}
}
#[must_use]
pub fn api_value_for_provider(self, provider: ApiProvider) -> Option<&'static str> {
if provider != ApiProvider::OpenaiCodex {
return self.api_value();
}
Some(match self.normalize_for_provider(provider) {
Self::Minimal => "low",
Self::Low => "low",
Self::Medium => "medium",
Self::High => "high",
Self::XHigh => "xhigh",
Self::Ultra => "ultra",
Self::Max => "max",
Self::Off => "low",
Self::Auto => "medium",
})
}
/// Provider-facing value after exact-route normalization.
#[must_use]
pub fn api_value_for_route(
self,
provider: ApiProvider,
base_url: &str,
wire_model: &str,
) -> Option<&'static str> {
self.normalize_for_route(provider, base_url, wire_model)
.api_value_for_provider(provider)
}
#[must_use]
pub fn as_setting_for_provider(self, provider: ApiProvider) -> &'static str {
self.api_value_for_provider(provider)
.unwrap_or_else(|| self.as_setting())
}
/// Persist the canonical setting after exact-route normalization.
#[must_use]
pub fn as_setting_for_route(
self,
provider: ApiProvider,
base_url: &str,
wire_model: &str,
) -> &'static str {
self.normalize_for_route(provider, base_url, wire_model)
.as_setting_for_provider(provider)
}
/// Cycle through the three behaviorally distinct tiers.
#[must_use]
pub fn cycle_next(self) -> Self {
match self {
Self::Off => Self::High,
Self::Auto => Self::Off,
Self::Minimal | Self::Low | Self::Medium | Self::High | Self::XHigh | Self::Ultra => {
Self::Max
}
Self::Max => Self::Off,
}
}
/// Advance through an exact-route effort list. Unknown current values
/// enter at the first listed tier so a persisted `max` on an `xhigh`
/// ladder, or `off` on an always-thinking model, still moves.
#[must_use]
pub fn cycle_next_in(self, efforts: &[Self]) -> Self {
if efforts.is_empty() {
return self.cycle_next();
}
if let Some(index) = self.index_in(efforts) {
return efforts[(index + 1) % efforts.len()];
}
efforts[0]
}
fn index_in(self, efforts: &[Self]) -> Option<usize> {
efforts
.iter()
.position(|&effort| effort == self)
.or_else(|| {
let aliases: &[Self] = match self {
Self::Max | Self::Ultra => &[Self::XHigh],
Self::XHigh => &[Self::Max],
Self::Minimal => &[Self::Low],
Self::Low => &[Self::Minimal],
_ => return None,
};
aliases
.iter()
.find_map(|alias| efforts.iter().position(|&effort| effort == *alias))
})
}
/// Cycle the unresolved auto-model preference without applying any
/// provider's normalization rules prematurely.
#[must_use]
pub fn cycle_next_for_auto_model(self) -> Self {
match self {
Self::Auto => Self::Off,
Self::Off => Self::Minimal,
Self::Minimal => Self::Low,
Self::Low => Self::Medium,
Self::Medium => Self::High,
Self::High => Self::XHigh,
Self::XHigh => Self::Ultra,
Self::Ultra => Self::Max,
Self::Max => Self::Auto,
}
}
}
+2 -2
View File
@@ -10,7 +10,7 @@
//! ## Reasoning-effort enum reuse
//!
//! [`RequestTuning::reasoning_effort`] reuses the canonical
//! [`crate::tui::app::ReasoningEffort`] enum rather than defining a local
//! [`crate::reasoning_preference::ReasoningEffort`] enum rather than defining a local
//! `Off/Low/Medium/High` copy. That enum is the single source of truth for the
//! effort tiers across the DeepSeek and Codex effort pickers, it is already
//! imported by sibling top-level modules (`auto_reasoning`, `model_routing`),
@@ -19,7 +19,7 @@
//! Defining a parallel local enum here would duplicate that surface and risk
//! drift, so we import the existing type.
//!
use crate::tui::app::ReasoningEffort;
use crate::reasoning_preference::ReasoningEffort;
/// Optional request-tuning knobs a caller may attach to a model request.
///
+1 -1
View File
@@ -6381,7 +6381,7 @@ fn provider_model_entry_for_api(
provider: ApiProvider,
model: String,
) -> ProviderModelEntry {
use crate::tui::app::ReasoningEffort;
use crate::reasoning_preference::ReasoningEffort;
use codewhale_config::route::CapabilityState;
let mut entry = ProviderModelEntry {
+1 -1
View File
@@ -1236,7 +1236,7 @@ impl RuntimeChatPrompt {
return Err("Runtime relay turns must use Chat mode.".to_string());
}
if let Some(reasoning) = self.reasoning_effort.as_deref()
&& crate::tui::app::ReasoningEffort::parse_strict(reasoning).is_err()
&& crate::reasoning_preference::ReasoningEffort::parse_strict(reasoning).is_err()
{
return Err("The Runtime Chat reasoning effort is invalid.".to_string());
}
+8 -6
View File
@@ -3080,8 +3080,10 @@ pub struct StartTurnRequest {
pub environment_id: Option<String>,
}
fn parse_runtime_reasoning_effort(value: &str) -> Result<crate::tui::app::ReasoningEffort> {
crate::tui::app::ReasoningEffort::parse_strict(value).map_err(anyhow::Error::msg)
fn parse_runtime_reasoning_effort(
value: &str,
) -> Result<crate::reasoning_preference::ReasoningEffort> {
crate::reasoning_preference::ReasoningEffort::parse_strict(value).map_err(anyhow::Error::msg)
}
fn canonical_runtime_reasoning_effort(value: Option<&str>) -> Result<Option<String>> {
@@ -3175,7 +3177,7 @@ fn runtime_turn_request_fingerprint(
prompt: &str,
input_summary: Option<&str>,
requested_model: &str,
reasoning_effort: Option<crate::tui::app::ReasoningEffort>,
reasoning_effort: Option<crate::reasoning_preference::ReasoningEffort>,
allowed_tools: Option<&[String]>,
policy: RuntimePolicyProjection,
allow_shell: bool,
@@ -8865,7 +8867,7 @@ impl RuntimeThreadManager {
let cfg_snapshot = self.config.read().clone();
let configured_reasoning_preference = cfg_snapshot
.reasoning_effort()
.map(crate::tui::app::ReasoningEffort::from_setting);
.map(crate::reasoning_preference::ReasoningEffort::from_setting);
// Runtime API precedence is explicit and stable: a turn override wins
// over its persisted thread default, which wins over normal config.
let reasoning_preference = turn_reasoning_preference
@@ -9016,10 +9018,10 @@ impl RuntimeThreadManager {
)?;
let auto_controls_reasoning = matches!(
reasoning_preference,
Some(crate::tui::app::ReasoningEffort::Auto)
Some(crate::reasoning_preference::ReasoningEffort::Auto)
);
let selected_reasoning = reasoning_preference.map(|effort| {
if effort == crate::tui::app::ReasoningEffort::Auto {
if effort == crate::reasoning_preference::ReasoningEffort::Auto {
crate::auto_reasoning::select(false, &prompt)
} else {
effort
+7 -7
View File
@@ -1823,7 +1823,7 @@ fn runtime_turn_operation_keys_are_bounded_scoped_and_fingerprint_execution_poli
"same prompt",
None,
"deepseek-v4-pro",
Some(crate::tui::app::ReasoningEffort::High),
Some(crate::reasoning_preference::ReasoningEffort::High),
Some(&tools),
policy,
false,
@@ -1843,7 +1843,7 @@ fn runtime_turn_operation_keys_are_bounded_scoped_and_fingerprint_execution_poli
"same prompt",
None,
"deepseek-v4-pro",
Some(crate::tui::app::ReasoningEffort::High),
Some(crate::reasoning_preference::ReasoningEffort::High),
Some(&tools),
policy,
false,
@@ -1858,7 +1858,7 @@ fn runtime_turn_operation_keys_are_bounded_scoped_and_fingerprint_execution_poli
"different prompt",
None,
"deepseek-v4-pro",
Some(crate::tui::app::ReasoningEffort::High),
Some(crate::reasoning_preference::ReasoningEffort::High),
Some(&tools),
policy,
false,
@@ -1873,7 +1873,7 @@ fn runtime_turn_operation_keys_are_bounded_scoped_and_fingerprint_execution_poli
"same prompt",
None,
"different-model",
Some(crate::tui::app::ReasoningEffort::High),
Some(crate::reasoning_preference::ReasoningEffort::High),
Some(&tools),
policy,
false,
@@ -1888,7 +1888,7 @@ fn runtime_turn_operation_keys_are_bounded_scoped_and_fingerprint_execution_poli
"same prompt",
None,
"deepseek-v4-pro",
Some(crate::tui::app::ReasoningEffort::Medium),
Some(crate::reasoning_preference::ReasoningEffort::Medium),
Some(&tools),
policy,
false,
@@ -1903,7 +1903,7 @@ fn runtime_turn_operation_keys_are_bounded_scoped_and_fingerprint_execution_poli
"same prompt",
None,
"deepseek-v4-pro",
Some(crate::tui::app::ReasoningEffort::High),
Some(crate::reasoning_preference::ReasoningEffort::High),
Some(&[]),
policy,
false,
@@ -1918,7 +1918,7 @@ fn runtime_turn_operation_keys_are_bounded_scoped_and_fingerprint_execution_poli
"same prompt",
None,
"deepseek-v4-pro",
Some(crate::tui::app::ReasoningEffort::High),
Some(crate::reasoning_preference::ReasoningEffort::High),
Some(&tools),
RuntimePolicyProjection::from_persisted("operate", Some("full_access"), true),
true,
+1 -1
View File
@@ -14,7 +14,7 @@ use serde::{Deserialize, Serialize};
use crate::config::{expand_path, normalize_model_name};
use crate::localization::normalize_configured_locale;
use crate::palette::{normalize_hex_rgb_color, normalize_theme_setting};
use crate::tui::app::ReasoningEffort;
use crate::reasoning_preference::ReasoningEffort;
use codewhale_config::resolve::Layer;
const SETTINGS_FILE_NAME: &str = "settings.toml";
+1 -1
View File
@@ -54,6 +54,7 @@ use crate::models::{
ContentBlock, Message, MessageRequest, MessageResponse, SystemPrompt, Tool, Usage,
is_incomplete_stop_reason, is_output_limit_stop_reason, stop_reason_detail,
};
use crate::reasoning_preference::ReasoningEffort;
use crate::request_tuning::RequestTuning;
use crate::tools::canonical_action::{
CANONICAL_ACTION_ALIASES, canonical_action_alias, is_action_family,
@@ -69,7 +70,6 @@ use crate::tools::spec::{
use crate::tools::todo::SharedTodoList;
#[cfg(test)]
use crate::tools::todo::TodoList;
use crate::tui::app::ReasoningEffort;
use crate::utils::spawn_supervised;
use crate::work_graph::{
EvidenceKind, EvidenceRef, OperationIntent, OperationOwnerSnapshot, OwnerState,
+1 -1
View File
@@ -48,7 +48,7 @@ use crate::dependencies::ExternalTool;
use crate::features::Feature;
use crate::llm_client::LlmClient;
use crate::models::{ContentBlock, Message, MessageRequest, SystemPrompt, Usage};
use crate::tui::app::ReasoningEffort;
use crate::reasoning_preference::ReasoningEffort;
use crate::utils::truncate_with_ellipsis;
use super::spec::{
+5 -5
View File
@@ -26,6 +26,7 @@ use crate::localization::{Locale, MessageId, resolve_locale, tr};
use crate::models::{Message, SystemPrompt, Tool, Usage};
use crate::palette::{self, UiTheme};
use crate::pricing::{CostCurrency, CostEstimate};
use crate::reasoning_preference::{EffectiveReasoningEffort, ReasoningEffort};
use crate::session_manager::{SessionContextReference, SessionMetadata, SessionWorkState};
use crate::settings::{InlineDiffMode, Settings};
use crate::tools::plan::{PlanState, SharedPlanState, new_shared_plan_state};
@@ -69,13 +70,12 @@ pub(crate) enum RedactionGateNotice {
}
pub use types::{
AppAction, AppMode, AppModeUi, AutomationAction, ComposerDensity, ComposerSubmitAction,
ComposerSubmitChord, InitialInput, McpUiAction, QueuedMessage, ReasoningEffort, ScreenMode,
SettingSelection, ShellJobAction, SubmitDisposition, TaskPanelEntry, TaskPanelEntryKind,
ToolCollapseMode, ToolDetailRecord, TranscriptSpacing, TuiOptions, VimMode,
ComposerSubmitChord, InitialInput, McpUiAction, QueuedMessage, ScreenMode, SettingSelection,
ShellJobAction, SubmitDisposition, TaskPanelEntry, TaskPanelEntryKind, ToolCollapseMode,
ToolDetailRecord, TranscriptSpacing, TuiOptions, VimMode,
};
pub(crate) use types::{
CacheReplayTarget, EffectiveReasoningEffort, GoalControlIntent, PendingGoalControl,
WORKFLOW_DRAFT_INSTRUCTION_PREFIX,
CacheReplayTarget, GoalControlIntent, PendingGoalControl, WORKFLOW_DRAFT_INSTRUCTION_PREFIX,
};
// === Types ===
-472
View File
@@ -83,45 +83,6 @@ impl AppModeUi for AppMode {
}
}
/// Reasoning-effort tier, mirrored across DeepSeek and Codex effort pickers.
///
/// The config file accepts every supported string value for forward-compat with
/// providers that expose the full spectrum; DeepSeek currently collapses
/// `Low`/`Medium` → `high`. OpenAI Codex normalizes inherited DeepSeek-only
/// `Off` to `Low` and keeps `XHigh`, `Max`, and `Ultra` distinct at the
/// provider boundary. The default keyboard cycler walks the three DeepSeek-distinct
/// tiers: `Off` → `High` → `Max` → `Off`; provider-aware callers should use
/// [`ReasoningEffort::cycle_next_in`] with the route's effort list. Auto
/// routing has no concrete provider yet, so
/// [`ReasoningEffort::cycle_next_for_auto_model`] retains the full
/// provider-neutral preference vocabulary until dispatch.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum ReasoningEffort {
Off,
Minimal,
Low,
Medium,
High,
XHigh,
Ultra,
Auto,
#[default]
Max,
}
/// Provider-effective reasoning state used by durable receipts and visible
/// requested-to-effective labels.
///
/// Some routes, notably first-party GLM-5-Turbo, support a thinking toggle but
/// publish no effort tiers. Keeping that state distinct prevents a requested
/// `max` from being displayed or persisted as an effective `max` claim.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum EffectiveReasoningEffort {
Tier(ReasoningEffort),
ThinkingEnabledGranularityUnavailable,
Unavailable,
}
/// Exact provider/model route whose prompt can be inspected or replayed.
///
/// Auto-model sessions keep `model == "auto"` as the user's selection, so
@@ -139,439 +100,6 @@ pub(crate) struct CacheReplayTarget {
pub(crate) base_url: Option<String>,
}
impl EffectiveReasoningEffort {
/// Reconstruct a safe request tier for cache replay and inspection.
///
/// Routes with an enabled-but-untiered receipt collapse every non-Off
/// request to the same wire toggle, so High is the canonical value that
/// keeps reasoning enabled without claiming a granular effective tier.
#[must_use]
pub(crate) const fn request_tier_for_replay(self) -> Option<ReasoningEffort> {
match self {
Self::Tier(tier) => Some(tier),
Self::ThinkingEnabledGranularityUnavailable => Some(ReasoningEffort::High),
Self::Unavailable => None,
}
}
}
impl From<EffectiveReasoningEffort> for crate::work_graph::ReasoningEffortTier {
fn from(value: EffectiveReasoningEffort) -> Self {
match value {
EffectiveReasoningEffort::Tier(tier) => tier.into(),
EffectiveReasoningEffort::ThinkingEnabledGranularityUnavailable => {
Self::ThinkingEnabledGranularityUnavailable
}
EffectiveReasoningEffort::Unavailable => Self::Unavailable,
}
}
}
impl From<crate::work_graph::ReasoningEffortTier> for EffectiveReasoningEffort {
fn from(value: crate::work_graph::ReasoningEffortTier) -> Self {
use crate::work_graph::ReasoningEffortTier as Tier;
match value {
Tier::Off => Self::Tier(ReasoningEffort::Off),
Tier::Minimal => Self::Tier(ReasoningEffort::Minimal),
Tier::Low => Self::Tier(ReasoningEffort::Low),
Tier::Medium => Self::Tier(ReasoningEffort::Medium),
Tier::High => Self::Tier(ReasoningEffort::High),
Tier::XHigh => Self::Tier(ReasoningEffort::XHigh),
Tier::Ultra => Self::Tier(ReasoningEffort::Ultra),
Tier::Auto => Self::Tier(ReasoningEffort::Auto),
Tier::Max => Self::Tier(ReasoningEffort::Max),
Tier::ThinkingEnabledGranularityUnavailable => {
Self::ThinkingEnabledGranularityUnavailable
}
Tier::Unavailable => Self::Unavailable,
}
}
}
impl From<ReasoningEffort> for crate::work_graph::ReasoningEffortTier {
fn from(value: ReasoningEffort) -> Self {
match value {
ReasoningEffort::Off => Self::Off,
ReasoningEffort::Minimal => Self::Minimal,
ReasoningEffort::Low => Self::Low,
ReasoningEffort::Medium => Self::Medium,
ReasoningEffort::High => Self::High,
ReasoningEffort::XHigh => Self::XHigh,
ReasoningEffort::Ultra => Self::Ultra,
ReasoningEffort::Auto => Self::Auto,
ReasoningEffort::Max => Self::Max,
}
}
}
impl ReasoningEffort {
/// Parse an operator-supplied effort value.
///
/// This is deliberately the one canonical spelling table for every
/// human-facing route. Callers that read an old persisted config may use
/// [`Self::from_setting`] for its compatibility fallback, but a new CLI,
/// settings, or tool input must reject an unknown value instead of quietly
/// turning it into `max`.
pub fn parse_strict(value: &str) -> Result<Self, String> {
let trimmed = value.trim();
match trimmed.to_ascii_lowercase().as_str() {
"off" | "disabled" | "none" | "false" => Ok(Self::Off),
"low" | "minimum" | "minimal" | "light" => Ok(Self::Low),
"medium" | "mid" => Ok(Self::Medium),
"high" => Ok(Self::High),
"xhigh" => Ok(Self::XHigh),
"auto" | "automatic" => Ok(Self::Auto),
"ultra" | "ultracode" => Ok(Self::Ultra),
"max" | "maximum" => Ok(Self::Max),
_ => Err(format!(
"Unrecognized reasoning effort {trimmed:?}. Expected: auto, off, low, medium, high, xhigh, max, or ultra."
)),
}
}
/// Parse a persisted config-file string into an effort tier. Unknown
/// legacy values fall back to the default (`Max`) so an old malformed
/// settings file never prevents startup. New user input should use
/// [`Self::parse_strict`] instead.
#[must_use]
pub fn from_setting(value: &str) -> Self {
Self::parse_strict(value).unwrap_or_default()
}
#[must_use]
pub fn from_setting_for_provider(value: &str, provider: ApiProvider) -> Self {
Self::from_setting(value).normalize_for_provider(provider)
}
/// Canonical lowercase label used for config storage and UI hints.
#[must_use]
pub fn as_setting(self) -> &'static str {
match self {
Self::Off => "off",
Self::Minimal => "minimal",
Self::Low => "low",
Self::Medium => "medium",
Self::High => "high",
Self::XHigh => "xhigh",
Self::Ultra => "ultra",
Self::Auto => "auto",
Self::Max => "max",
}
}
/// Short label for the header chip.
#[must_use]
pub fn short_label(self) -> &'static str {
match self {
Self::Off => "off",
Self::Minimal => "minimal",
Self::Low => "low",
Self::Medium => "med",
Self::High => "high",
Self::XHigh => "xhigh",
Self::Ultra => "ultra",
Self::Auto => "auto",
Self::Max => "max",
}
}
/// Provider-facing label for user-visible surfaces.
#[must_use]
pub fn display_label_for_provider(self, provider: ApiProvider) -> &'static str {
match (provider, self.normalize_for_provider(provider)) {
(ApiProvider::OpenaiCodex, Self::Minimal) => "low",
(ApiProvider::OpenaiCodex, Self::Low) => "low",
(ApiProvider::OpenaiCodex, Self::Medium) => "medium",
(ApiProvider::OpenaiCodex, Self::High) => "high",
// `xhigh`, `max` and `ultra` are three distinct rungs the Codex
// roster publishes per model; collapsing them onto "xhigh" dates
// from when xhigh was the ceiling and made the top tiers
// unreachable and indistinguishable.
(ApiProvider::OpenaiCodex, Self::XHigh) => "xhigh",
(ApiProvider::OpenaiCodex, Self::Ultra) => "ultra",
(ApiProvider::OpenaiCodex, Self::Max) => "max",
(ApiProvider::Xai, Self::XHigh) => "xhigh",
(_, effort) => effort.short_label(),
}
}
/// Value forwarded to the engine/client. `None` means "provider default"
/// (for `Off` we still emit `"off"` so the client can inject
/// `thinking = {"type": "disabled"}`).
#[must_use]
pub fn api_value(self) -> Option<&'static str> {
Some(self.as_setting())
}
#[must_use]
pub fn normalize_for_provider(self, provider: ApiProvider) -> Self {
if provider != ApiProvider::OpenaiCodex {
return self;
}
match self {
Self::Off => Self::Low,
Self::Auto => Self::Medium,
other => other,
}
}
/// Resolve an effort against the exact provider route that will receive
/// the request. Both K3 routes are always-thinking, so `off` becomes the
/// lowest supported tier. The Kimi Code membership route otherwise keeps
/// its low/high/max mapping; direct Moonshot K3 additionally maps `medium`
/// to `high`. First-party DeepSeek routes keep `low` (the wire documents
/// low/high/max) while rounding `medium` up to `high`. Models that publish
/// a Models.dev `reasoning_options` effort list keep that vocabulary
/// instead of the historic Low/Medium collapse. Generic Moonshot and
/// every other non-Codex route retain the historic high coercion.
/// This intentionally does not change [`Self::normalize_for_provider`],
/// whose generic wire semantics are used by older callers that do not yet
/// have a route receipt.
#[must_use]
pub fn normalize_for_route(
self,
provider: ApiProvider,
base_url: &str,
wire_model: &str,
) -> Self {
let normalized = self.normalize_for_provider(provider);
if crate::config::is_exact_kimi_code_k3_route(provider, base_url, wire_model) {
return match normalized {
Self::Off => Self::Low,
other => other,
};
}
if crate::config::is_exact_direct_moonshot_k3_route(provider, base_url, wire_model) {
return match normalized {
Self::Off => Self::Low,
Self::Medium => Self::High,
other => other,
};
}
if provider == ApiProvider::OpenaiCodex {
return normalized;
}
// First-party DeepSeek routes document `reasoning_effort` low/high/max
// on the wire (no medium), so `low` is a real, cheaper tier there and
// must reach the wire as low; `medium` rounds up to high because the
// dialect has no such value (#52).
if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {
return match normalized {
Self::Low => Self::Low,
Self::Medium => Self::High,
other => other,
};
}
// Ollama's current OpenAI-compatible Chat Completions contract
// documents the complete none/low/medium/high/max ladder. Keep every
// real tier distinct for normal turns; only Codewhale-only synonyms
// are folded onto the nearest documented spelling.
if provider == ApiProvider::OllamaCloud {
return match normalized {
Self::Minimal => Self::Low,
Self::XHigh | Self::Ultra => Self::Max,
other => other,
};
}
if let Some(values) = Self::catalog_effort_values(provider, wire_model) {
return Self::clamp_to_catalog_efforts(normalized, provider, wire_model, &values);
}
match normalized {
Self::Low | Self::Medium => Self::High,
other => other,
}
}
pub(crate) fn catalog_default(provider: ApiProvider, wire_model: &str) -> Option<Self> {
let offering = crate::provider_lake::catalog_offering_for_model(provider, wire_model)?;
offering.reasoning_options.iter().find_map(|option| {
option
.get("type")
.and_then(|value| value.as_str())
.filter(|kind| kind.eq_ignore_ascii_case("effort"))?;
option
.get("default")
.and_then(|value| value.as_str())
.and_then(Self::from_catalog_token)
})
}
pub(crate) fn catalog_effort_values(
provider: ApiProvider,
wire_model: &str,
) -> Option<Vec<Self>> {
let offering = crate::provider_lake::catalog_offering_for_model(provider, wire_model)?;
let mut efforts = Vec::new();
for option in &offering.reasoning_options {
if !option
.get("type")
.and_then(|value| value.as_str())
.is_some_and(|kind| kind.eq_ignore_ascii_case("effort"))
{
continue;
}
let Some(values) = option.get("values").and_then(|value| value.as_array()) else {
continue;
};
for value in values {
if let Some(effort) = value.as_str().and_then(Self::from_catalog_token)
&& !efforts.contains(&effort)
{
efforts.push(effort);
}
}
}
(!efforts.is_empty()).then_some(efforts)
}
pub(crate) fn from_catalog_token(raw: &str) -> Option<Self> {
match raw.trim().to_ascii_lowercase().as_str() {
"off" | "disabled" | "none" | "false" => Some(Self::Off),
"minimal" | "minimum" => Some(Self::Minimal),
"low" | "light" => Some(Self::Low),
"medium" | "mid" => Some(Self::Medium),
"high" => Some(Self::High),
"xhigh" => Some(Self::XHigh),
"ultra" | "ultracode" => Some(Self::Ultra),
"max" | "maximum" => Some(Self::Max),
"auto" | "automatic" | "adaptive" => Some(Self::Auto),
_ => None,
}
}
fn clamp_to_catalog_efforts(
normalized: Self,
provider: ApiProvider,
wire_model: &str,
values: &[Self],
) -> Self {
if matches!(normalized, Self::Auto) || values.contains(&normalized) {
return normalized;
}
let aliased = match normalized {
Self::Minimal if values.contains(&Self::Low) => Self::Low,
Self::Max | Self::Ultra if values.contains(&Self::XHigh) => Self::XHigh,
Self::Off => Self::catalog_default(provider, wire_model).unwrap_or(Self::High),
other => other,
};
if values.contains(&aliased) {
aliased
} else {
Self::catalog_default(provider, wire_model).unwrap_or(Self::High)
}
}
#[must_use]
pub fn api_value_for_provider(self, provider: ApiProvider) -> Option<&'static str> {
if provider != ApiProvider::OpenaiCodex {
return self.api_value();
}
Some(match self.normalize_for_provider(provider) {
Self::Minimal => "low",
Self::Low => "low",
Self::Medium => "medium",
Self::High => "high",
Self::XHigh => "xhigh",
Self::Ultra => "ultra",
Self::Max => "max",
Self::Off => "low",
Self::Auto => "medium",
})
}
/// Provider-facing value after exact-route normalization.
#[must_use]
pub fn api_value_for_route(
self,
provider: ApiProvider,
base_url: &str,
wire_model: &str,
) -> Option<&'static str> {
self.normalize_for_route(provider, base_url, wire_model)
.api_value_for_provider(provider)
}
#[must_use]
pub fn as_setting_for_provider(self, provider: ApiProvider) -> &'static str {
self.api_value_for_provider(provider)
.unwrap_or_else(|| self.as_setting())
}
/// Persist the canonical setting after exact-route normalization.
#[must_use]
pub fn as_setting_for_route(
self,
provider: ApiProvider,
base_url: &str,
wire_model: &str,
) -> &'static str {
self.normalize_for_route(provider, base_url, wire_model)
.as_setting_for_provider(provider)
}
/// Cycle through the three behaviorally distinct tiers.
#[must_use]
pub fn cycle_next(self) -> Self {
match self {
Self::Off => Self::High,
Self::Auto => Self::Off,
Self::Minimal | Self::Low | Self::Medium | Self::High | Self::XHigh | Self::Ultra => {
Self::Max
}
Self::Max => Self::Off,
}
}
/// Advance through an exact-route effort list. Unknown current values
/// enter at the first listed tier so a persisted `max` on an `xhigh`
/// ladder, or `off` on an always-thinking model, still moves.
#[must_use]
pub fn cycle_next_in(self, efforts: &[Self]) -> Self {
if efforts.is_empty() {
return self.cycle_next();
}
if let Some(index) = self.index_in(efforts) {
return efforts[(index + 1) % efforts.len()];
}
efforts[0]
}
fn index_in(self, efforts: &[Self]) -> Option<usize> {
efforts
.iter()
.position(|&effort| effort == self)
.or_else(|| {
let aliases: &[Self] = match self {
Self::Max | Self::Ultra => &[Self::XHigh],
Self::XHigh => &[Self::Max],
Self::Minimal => &[Self::Low],
Self::Low => &[Self::Minimal],
_ => return None,
};
aliases
.iter()
.find_map(|alias| efforts.iter().position(|&effort| effort == *alias))
})
}
/// Cycle the unresolved auto-model preference without applying any
/// provider's normalization rules prematurely.
#[must_use]
pub fn cycle_next_for_auto_model(self) -> Self {
match self {
Self::Auto => Self::Off,
Self::Off => Self::Minimal,
Self::Minimal => Self::Low,
Self::Low => Self::Medium,
Self::Medium => Self::High,
Self::High => Self::XHigh,
Self::XHigh => Self::Ultra,
Self::Ultra => Self::Max,
Self::Max => Self::Auto,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ComposerDensity {
Compact,
+3 -2
View File
@@ -975,7 +975,7 @@ impl HotbarAction for AppHotbarAction {
AppHotbarKind::SessionCompact => app.is_compacting || app.manual_compaction_queued,
AppHotbarKind::Mode(mode) => app.mode == mode,
AppHotbarKind::ReasoningCycle => {
app.reasoning_effort != crate::tui::app::ReasoningEffort::Off
app.reasoning_effort != crate::reasoning_preference::ReasoningEffort::Off
}
AppHotbarKind::SidebarToggle => {
app.work_surface.placement != crate::tui::work_surface::WorkSurfacePlacement::Off
@@ -1439,7 +1439,8 @@ mod tests {
use std::path::PathBuf;
use crate::config::{ApiProvider, Config};
use crate::tui::app::{ReasoningEffort, TuiOptions};
use crate::reasoning_preference::ReasoningEffort;
use crate::tui::app::TuiOptions;
use crate::tui::views::ModalKind;
use super::*;
+2 -1
View File
@@ -39,8 +39,9 @@ use crate::palette;
use crate::provider_lake::{
catalog_offering_for_model, catalog_offering_for_model_identity, configured_providers,
};
use crate::reasoning_preference::ReasoningEffort;
use crate::settings::PinnedModel;
use crate::tui::app::{App, ReasoningEffort};
use crate::tui::app::App;
use crate::tui::menu_style;
use crate::tui::views::{
ActionHint, ListDetailLayout, ModalKind, ModalView, ViewAction, ViewEvent, render_modal_footer,
+1 -1
View File
@@ -48,7 +48,7 @@ use crate::provider_readiness::{
CredentialState, ProviderReadinessSnapshot, ProviderRouteIdentity, ResolvedProviderReadiness,
credential_state_for_provider, route_identity_for_model,
};
use crate::tui::app::ReasoningEffort;
use crate::reasoning_preference::ReasoningEffort;
use crate::tui::menu_style;
use crate::tui::views::{
ActionHint, EmptyState, ListDetailLayout, ModalKind, ModalView, ViewAction, ViewEvent,
+9 -9
View File
@@ -70,15 +70,15 @@ pub fn visible_slash_menu_entries(app: &App, limit: usize) -> Vec<SlashMenuEntry
effort_entries.push(SlashMenuEntry {
name: format!("/effort {}", label),
description: match eff {
crate::tui::app::ReasoningEffort::Auto => "choose per turn".into(),
crate::tui::app::ReasoningEffort::Off => "no extra reasoning".into(),
crate::tui::app::ReasoningEffort::Minimal => "minimal reasoning".into(),
crate::tui::app::ReasoningEffort::Low => "lighter reasoning".into(),
crate::tui::app::ReasoningEffort::Medium => "balanced reasoning".into(),
crate::tui::app::ReasoningEffort::High => "deeper reasoning".into(),
crate::tui::app::ReasoningEffort::XHigh => "extra-high reasoning".into(),
crate::tui::app::ReasoningEffort::Ultra => "ultra reasoning".into(),
crate::tui::app::ReasoningEffort::Max => "maximum reasoning".into(),
crate::reasoning_preference::ReasoningEffort::Auto => "choose per turn".into(),
crate::reasoning_preference::ReasoningEffort::Off => "no extra reasoning".into(),
crate::reasoning_preference::ReasoningEffort::Minimal => "minimal reasoning".into(),
crate::reasoning_preference::ReasoningEffort::Low => "lighter reasoning".into(),
crate::reasoning_preference::ReasoningEffort::Medium => "balanced reasoning".into(),
crate::reasoning_preference::ReasoningEffort::High => "deeper reasoning".into(),
crate::reasoning_preference::ReasoningEffort::XHigh => "extra-high reasoning".into(),
crate::reasoning_preference::ReasoningEffort::Ultra => "ultra reasoning".into(),
crate::reasoning_preference::ReasoningEffort::Max => "maximum reasoning".into(),
},
is_skill: false,
alias_hint: None,
+7 -5
View File
@@ -131,15 +131,17 @@ use crate::tui::views::subagent_view_agents;
use crate::tui::vim_mode;
use crate::tui::workspace_context;
use crate::reasoning_preference::{EffectiveReasoningEffort, ReasoningEffort};
use super::key_actions;
use super::app::{
ActiveCompaction, ActiveTurnMetadata, AgentCurrentActivity, App, AppAction, AppMode,
ComposerSubmitAction, ComposerSubmitChord, EffectiveReasoningEffort, GoalControlIntent,
OnboardingState, PendingGoalControl, PendingProviderSwitch, QueuedMessage, ReasoningEffort,
RedactionGateNotice, ScreenMode, StatusToast, StatusToastLevel, SubmitDisposition,
TaskPanelEntry, TaskPanelEntryKind, ToolEvidence, TuiOptions, bound_agent_activity_text,
is_stop_word, looks_like_slash_command_input, shell_command_from_bang_input,
ComposerSubmitAction, ComposerSubmitChord, GoalControlIntent, OnboardingState,
PendingGoalControl, PendingProviderSwitch, QueuedMessage, RedactionGateNotice, ScreenMode,
StatusToast, StatusToastLevel, SubmitDisposition, TaskPanelEntry, TaskPanelEntryKind,
ToolEvidence, TuiOptions, bound_agent_activity_text, is_stop_word,
looks_like_slash_command_input, shell_command_from_bang_input,
};
use super::approval::{
ApprovalMode, ApprovalRequest, ApprovalView, ElevationRequest, ElevationView, ReviewDecision,
+2 -2
View File
@@ -874,9 +874,9 @@ pub(crate) async fn apply_model_picker_choice(
model: String,
target_provider: Option<ApiProvider>,
target_provider_id: Option<String>,
effort: crate::tui::app::ReasoningEffort,
effort: crate::reasoning_preference::ReasoningEffort,
previous_model: String,
previous_effort: crate::tui::app::ReasoningEffort,
previous_effort: crate::reasoning_preference::ReasoningEffort,
save_as_startup_default: bool,
) {
if app.reject_setting_change_while_busy(
+1 -1
View File
@@ -2460,7 +2460,7 @@ mod tests {
app.auto_model = false;
app.active_route_base_url = "https://gateway.example/v1".to_string();
app.model = "vendor-model-x".to_string();
app.reasoning_effort = crate::tui::app::ReasoningEffort::High;
app.reasoning_effort = crate::reasoning_preference::ReasoningEffort::High;
assert_eq!(
app.reasoning_effort_display_label(),
"high→effective unavailable",
@@ -653,7 +653,7 @@ fn statusline_full_frame_custom_cost_preserves_evidence_and_width_shedding() {
app.set_provider_identity(ApiProvider::Custom, "my-gateway");
app.active_route_base_url = "https://gateway.example/v1".to_string();
app.model = "vendor-model-x".to_string();
app.reasoning_effort = crate::tui::app::ReasoningEffort::High;
app.reasoning_effort = crate::reasoning_preference::ReasoningEffort::High;
app.billing_presentation = BillingPresentation::Unknown;
app.session.cost_coverage_unknown_legacy = true;
app.status_items = vec![StatusItem::ContextPercent, StatusItem::Cost];
+2 -1
View File
@@ -18,8 +18,9 @@ use crate::config::{
ProviderConfig, ProvidersConfig,
};
use crate::core::engine::mock_engine_handle;
use crate::reasoning_preference::ReasoningEffort;
use crate::tui::active_cell::ActiveCell;
use crate::tui::app::{ReasoningEffort, ToolDetailRecord};
use crate::tui::app::ToolDetailRecord;
use crate::tui::file_mention::{
apply_mention_menu_selection, find_file_mention_completions, partial_file_mention_at_cursor,
try_autocomplete_file_mention, user_request_with_file_mentions, visible_mention_menu_entries,
+3 -3
View File
@@ -748,9 +748,9 @@ pub enum ViewEvent {
/// Exact named custom route key when the selected provider enum is
/// `Custom`; built-in routes leave this unset.
provider_id: Option<String>,
effort: crate::tui::app::ReasoningEffort,
effort: crate::reasoning_preference::ReasoningEffort,
previous_model: String,
previous_effort: crate::tui::app::ReasoningEffort,
previous_effort: crate::reasoning_preference::ReasoningEffort,
save_as_startup_default: bool,
},
/// Emitted by the `/model` picker on Esc so the next open can restore
@@ -2082,7 +2082,7 @@ impl ConfigView {
value: settings.reasoning_effort.as_deref().map_or_else(
|| tr(app.ui_locale, MessageId::ConfigDefaultReasoning).to_string(),
|value| {
crate::tui::app::ReasoningEffort::from_setting_for_provider(
crate::reasoning_preference::ReasoningEffort::from_setting_for_provider(
value,
app.api_provider,
)
+2 -1
View File
@@ -19,10 +19,11 @@
use crate::compaction::CompactionConfig;
use crate::config::{ApiProvider, Config, ProviderIdentity};
use crate::reasoning_preference::ReasoningEffort;
use crate::route_runtime::{
ResolvedRuntimeRoute, resolve_runtime_route, resolve_runtime_route_for_identity,
};
use crate::tui::app::{AppMode, ReasoningEffort};
use crate::tui::app::AppMode;
/// Everything the shared turn-route planner needs.
///