From 1ca8acf3ac3767514e77f63ec03abf30e0503557 Mon Sep 17 00:00:00 2001 From: laststylebender14 Date: Mon, 9 Mar 2026 15:53:27 +0530 Subject: [PATCH 01/12] feat(config): add model-reasoning command to set reasoning effort --- crates/forge_api/src/api.rs | 19 ++- crates/forge_api/src/forge_api.rs | 20 +++ crates/forge_app/src/command_generator.rs | 17 +++ crates/forge_app/src/services.rs | 65 ++++++++- crates/forge_domain/src/agent_definition.rs | 4 +- crates/forge_domain/src/app_config.rs | 12 +- crates/forge_main/src/built_in_commands.json | 4 + crates/forge_main/src/cli.rs | 132 +++++++++++++++---- crates/forge_main/src/ui.rs | 73 +++++++--- crates/forge_services/src/app_config.rs | 53 +++++++- shell-plugin/lib/actions/config.zsh | 53 ++++++++ shell-plugin/lib/dispatcher.zsh | 3 + 12 files changed, 411 insertions(+), 44 deletions(-) diff --git a/crates/forge_api/src/api.rs b/crates/forge_api/src/api.rs index 89c8cdd495..c7bbb2de19 100644 --- a/crates/forge_api/src/api.rs +++ b/crates/forge_api/src/api.rs @@ -3,7 +3,7 @@ use std::path::{Path, PathBuf}; use anyhow::Result; use forge_app::dto::ToolsOverview; use forge_app::{User, UserUsage}; -use forge_domain::{AgentId, InitAuth, ModelId, ProviderModels}; +use forge_domain::{AgentId, InitAuth, ModelId, ProviderId, ProviderModels}; use forge_stream::MpscStream; use futures::stream::BoxStream; use url::Url; @@ -157,6 +157,23 @@ pub trait API: Sync + Send { /// Sets the operating model async fn set_default_model(&self, model_id: ModelId) -> anyhow::Result<()>; + /// Sets reasoning configuration for a specific model under a specific + /// provider. + async fn set_model_reasoning( + &self, + provider_id: ProviderId, + model_id: ModelId, + reasoning: Option, + ) -> anyhow::Result<()>; + + /// Gets the reasoning configuration for a specific model under a specific + /// provider, if set. + async fn get_model_reasoning( + &self, + provider_id: &ProviderId, + model_id: &ModelId, + ) -> anyhow::Result>; + /// Refresh MCP caches by fetching fresh data async fn reload_mcp(&self) -> Result<()>; diff --git a/crates/forge_api/src/forge_api.rs b/crates/forge_api/src/forge_api.rs index 43f2e242a4..ac32d99bb0 100644 --- a/crates/forge_api/src/forge_api.rs +++ b/crates/forge_api/src/forge_api.rs @@ -293,6 +293,26 @@ impl< result } + async fn set_model_reasoning( + &self, + provider_id: ProviderId, + model_id: ModelId, + reasoning: Option, + ) -> anyhow::Result<()> { + self + .services + .set_model_reasoning(provider_id, model_id, reasoning) + .await + } + + async fn get_model_reasoning( + &self, + provider_id: &ProviderId, + model_id: &ModelId, + ) -> anyhow::Result> { + self.services.get_model_reasoning(provider_id, model_id).await + } + async fn get_login_info(&self) -> Result> { self.services.auth_service().get_auth_token().await } diff --git a/crates/forge_app/src/command_generator.rs b/crates/forge_app/src/command_generator.rs index 3ddf728bfc..588f82b83d 100644 --- a/crates/forge_app/src/command_generator.rs +++ b/crates/forge_app/src/command_generator.rs @@ -241,6 +241,23 @@ mod tests { async fn set_default_model(&self, _model: ModelId) -> Result<()> { Ok(()) } + + async fn set_model_reasoning( + &self, + _provider_id: ProviderId, + _model_id: ModelId, + _reasoning: Option, + ) -> Result<()> { + Ok(()) + } + + async fn get_model_reasoning( + &self, + _provider_id: &ProviderId, + _model_id: &ModelId, + ) -> anyhow::Result> { + Ok(None) + } } #[tokio::test] diff --git a/crates/forge_app/src/services.rs b/crates/forge_app/src/services.rs index 04d8a000b8..9e912d7267 100644 --- a/crates/forge_app/src/services.rs +++ b/crates/forge_app/src/services.rs @@ -214,6 +214,23 @@ pub trait AppConfigService: Send + Sync { /// # Errors /// Returns an error if no default provider is configured. async fn set_default_model(&self, model: ModelId) -> anyhow::Result<()>; + + /// Sets reasoning configuration for a specific model under a specific + /// provider. + async fn set_model_reasoning( + &self, + provider_id: ProviderId, + model_id: ModelId, + reasoning: Option, + ) -> anyhow::Result<()>; + + /// Gets the reasoning configuration for a specific model under a specific + /// provider, if set. + async fn get_model_reasoning( + &self, + provider_id: &ProviderId, + model_id: &ModelId, + ) -> anyhow::Result>; } #[async_trait::async_trait] @@ -976,11 +993,34 @@ impl AgentRegistry for I { } async fn get_agents(&self) -> anyhow::Result> { - self.agent_registry().get_agents().await + let mut agents = self.agent_registry().get_agents().await?; + for agent in &mut agents { + if let Ok(Some(reasoning)) = self + .config_service() + .get_model_reasoning(&agent.provider, &agent.model) + .await + { + agent.reasoning = Some(reasoning); + } + } + Ok(agents) } async fn get_agent(&self, agent_id: &AgentId) -> anyhow::Result> { - self.agent_registry().get_agent(agent_id).await + let agent = self.agent_registry().get_agent(agent_id).await?; + match agent { + Some(mut agent) => { + if let Ok(Some(reasoning)) = self + .config_service() + .get_model_reasoning(&agent.provider, &agent.model) + .await + { + agent.reasoning = Some(reasoning); + } + Ok(Some(agent)) + } + None => Ok(None), + } } async fn reload_agents(&self) -> anyhow::Result<()> { @@ -1032,6 +1072,27 @@ impl AppConfigService for I { async fn set_default_model(&self, model: ModelId) -> anyhow::Result<()> { self.config_service().set_default_model(model).await } + + async fn set_model_reasoning( + &self, + provider_id: ProviderId, + model_id: ModelId, + reasoning: Option, + ) -> anyhow::Result<()> { + self.config_service() + .set_model_reasoning(provider_id, model_id, reasoning) + .await + } + + async fn get_model_reasoning( + &self, + provider_id: &ProviderId, + model_id: &ModelId, + ) -> anyhow::Result> { + self.config_service() + .get_model_reasoning(provider_id, model_id) + .await + } } #[async_trait::async_trait] diff --git a/crates/forge_domain/src/agent_definition.rs b/crates/forge_domain/src/agent_definition.rs index ec7165f63b..eb52318195 100644 --- a/crates/forge_domain/src/agent_definition.rs +++ b/crates/forge_domain/src/agent_definition.rs @@ -5,7 +5,7 @@ use derive_setters::Setters; use merge::Merge; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use strum_macros::Display as StrumDisplay; +use strum_macros::{Display as StrumDisplay, EnumString}; use crate::compact::Compact; use crate::temperature::Temperature; @@ -213,7 +213,7 @@ pub struct ReasoningConfig { pub enabled: Option, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, StrumDisplay)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, StrumDisplay, EnumString)] #[serde(rename_all = "lowercase")] #[strum(serialize_all = "lowercase")] pub enum Effort { diff --git a/crates/forge_domain/src/app_config.rs b/crates/forge_domain/src/app_config.rs index 300eff9415..4244bb5619 100644 --- a/crates/forge_domain/src/app_config.rs +++ b/crates/forge_domain/src/app_config.rs @@ -3,7 +3,7 @@ use std::collections::HashMap; use derive_more::From; use serde::{Deserialize, Serialize}; -use crate::{ModelId, ProviderId}; +use crate::{ModelId, ProviderId, ReasoningConfig}; #[derive(Deserialize)] #[serde(rename_all = "camelCase")] @@ -13,6 +13,14 @@ pub struct InitAuth { pub token: String, } +/// Per-model configuration that can be set at runtime. +#[derive(Default, Clone, Serialize, Deserialize, Debug, PartialEq)] +pub struct ModelConfig { + /// Reasoning configuration for this specific model. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reasoning: Option, +} + #[derive(Default, Clone, Serialize, Deserialize, Debug, PartialEq)] #[serde(rename_all = "camelCase")] pub struct AppConfig { @@ -21,6 +29,8 @@ pub struct AppConfig { pub provider: Option, #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub model: HashMap, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub provider_config: HashMap>, } #[derive(Clone, Serialize, Deserialize, From, Debug, PartialEq)] diff --git a/crates/forge_main/src/built_in_commands.json b/crates/forge_main/src/built_in_commands.json index 1bf786a2d1..4b76751350 100644 --- a/crates/forge_main/src/built_in_commands.json +++ b/crates/forge_main/src/built_in_commands.json @@ -15,6 +15,10 @@ "command": "model", "description": "Switch the models [alias: m]" }, + { + "command": "model-reasoning", + "description": "Set reasoning effort for the current model [alias: mr]" + }, { "command": "new", "description": "Start new conversation [alias: n]" diff --git a/crates/forge_main/src/cli.rs b/crates/forge_main/src/cli.rs index 08f84be11e..d9fd256cb9 100644 --- a/crates/forge_main/src/cli.rs +++ b/crates/forge_main/src/cli.rs @@ -519,28 +519,60 @@ pub enum ConfigCommand { List, } +/// Arguments for `forge config set`. #[derive(Parser, Debug, Clone)] pub struct ConfigSetArgs { - /// Configuration field to set. - pub field: ConfigField, - - /// Value to set. - pub value: String, + #[command(subcommand)] + pub field: ConfigSetField, } -/// Configuration fields that can be managed. -#[derive(clap::ValueEnum, Debug, Clone, Copy, PartialEq, Eq)] -pub enum ConfigField { - /// The active model. - Model, - /// The active provider. - Provider, +/// Type-safe subcommands for `forge config set`. +#[derive(Subcommand, Debug, Clone)] +pub enum ConfigSetField { + /// Set the active model. + Model { + /// Model ID to set as default. + model: String, + }, + /// Set the active provider. + Provider { + /// Provider ID to set as default. + provider: String, + }, + /// Set reasoning effort for a specific provider and model. + #[command(name = "model-reasoning")] + ModelReasoning { + /// Provider ID. + provider: String, + /// Model ID. + model: String, + /// Reasoning effort (low, medium, high, or "none" to clear). + effort: String, + }, } +/// Arguments for `forge config get`. #[derive(Parser, Debug, Clone)] pub struct ConfigGetArgs { - /// Configuration field to get. - pub field: ConfigField, + #[command(subcommand)] + pub field: ConfigGetField, +} + +/// Type-safe subcommands for `forge config get`. +#[derive(Subcommand, Debug, Clone)] +pub enum ConfigGetField { + /// Get the active model. + Model, + /// Get the active provider. + Provider, + /// Get reasoning effort for a specific provider and model. + #[command(name = "model-reasoning")] + ModelReasoning { + /// Provider ID. + provider: String, + /// Model ID. + model: String, + }, } /// Command group for conversation management. @@ -815,9 +847,10 @@ mod tests { ]); let actual = match fixture.subcommands { Some(TopLevelCommand::Config(config)) => match config.command { - ConfigCommand::Set(args) if args.field == ConfigField::Model => { - Some(args.value.clone()) - } + ConfigCommand::Set(args) => match args.field { + ConfigSetField::Model { model } => Some(model), + _ => None, + }, _ => None, }, _ => None, @@ -831,9 +864,10 @@ mod tests { let fixture = Cli::parse_from(["forge", "config", "set", "provider", "OpenAI"]); let actual = match fixture.subcommands { Some(TopLevelCommand::Config(config)) => match config.command { - ConfigCommand::Set(args) if args.field == ConfigField::Provider => { - Some(args.value.clone()) - } + ConfigCommand::Set(args) => match args.field { + ConfigSetField::Provider { provider } => Some(provider), + _ => None, + }, _ => None, }, _ => None, @@ -858,12 +892,66 @@ mod tests { let fixture = Cli::parse_from(["forge", "config", "get", "model"]); let actual = match fixture.subcommands { Some(TopLevelCommand::Config(config)) => match config.command { - ConfigCommand::Get(args) => args.field, + ConfigCommand::Get(args) => matches!(args.field, ConfigGetField::Model), _ => panic!("Expected ConfigCommand::Get"), }, _ => panic!("Expected TopLevelCommand::Config"), }; - let expected = ConfigField::Model; + assert!(actual); + } + + #[test] + fn test_config_set_model_reasoning() { + let fixture = Cli::parse_from([ + "forge", + "config", + "set", + "model-reasoning", + "anthropic", + "claude-sonnet-4", + "high", + ]); + let actual = match fixture.subcommands { + Some(TopLevelCommand::Config(config)) => match config.command { + ConfigCommand::Set(args) => match args.field { + ConfigSetField::ModelReasoning { provider, model, effort } => { + Some((provider, model, effort)) + } + _ => None, + }, + _ => None, + }, + _ => None, + }; + let expected = Some(( + "anthropic".to_string(), + "claude-sonnet-4".to_string(), + "high".to_string(), + )); + assert_eq!(actual, expected); + } + + #[test] + fn test_config_get_model_reasoning() { + let fixture = Cli::parse_from([ + "forge", + "config", + "get", + "model-reasoning", + "anthropic", + "claude-sonnet-4", + ]); + let actual = match fixture.subcommands { + Some(TopLevelCommand::Config(config)) => match config.command { + ConfigCommand::Get(args) => match args.field { + ConfigGetField::ModelReasoning { provider, model } => Some((provider, model)), + _ => None, + }, + _ => None, + }, + _ => None, + }; + let expected = Some(("anthropic".to_string(), "claude-sonnet-4".to_string())); assert_eq!(actual, expected); } diff --git a/crates/forge_main/src/ui.rs b/crates/forge_main/src/ui.rs index 217cb8a82c..cd3d00106e 100644 --- a/crates/forge_main/src/ui.rs +++ b/crates/forge_main/src/ui.rs @@ -3130,27 +3130,56 @@ impl A + Send + Sync> UI { /// Handle config set command async fn handle_config_set(&mut self, args: crate::cli::ConfigSetArgs) -> Result<()> { - use crate::cli::ConfigField; + use crate::cli::ConfigSetField; - // Set the specified field match args.field { - ConfigField::Provider => { - // Parse provider ID (any string is valid for custom providers) + ConfigSetField::Provider { provider } => { let provider_id = - ProviderId::from_str(&args.value).expect("from_str is infallible"); + ProviderId::from_str(&provider).expect("from_str is infallible"); - // Get the provider let provider = self.api.get_provider(&provider_id).await?; - // Activate the provider (will configure if needed and set as default) self.activate_provider(provider).await?; } - ConfigField::Model => { - let model_id = self.validate_model(&args.value).await?; + ConfigSetField::Model { model } => { + let model_id = self.validate_model(&model).await?; self.api.set_default_model(model_id.clone()).await?; self.writeln_title( TitleFormat::action(model_id.as_str()).sub_title("is now the default model"), )?; } + ConfigSetField::ModelReasoning { provider, model, effort } => { + let provider_id = + ProviderId::from_str(&provider).expect("from_str is infallible"); + let model_id = ModelId::new(&model); + + let value_lower = effort.to_lowercase(); + let reasoning = if value_lower == "none" || value_lower == "off" { + None + } else { + use std::str::FromStr; + let effort = forge_domain::Effort::from_str(&value_lower) + .map_err(|_| anyhow::anyhow!("Invalid reasoning effort '{}'. Valid values: low, medium, high, none", effort))?; + Some( + forge_domain::ReasoningConfig::default() + .effort(effort.clone()), + ) + }; + + self.api + .set_model_reasoning(provider_id, model_id.clone(), reasoning.clone()) + .await?; + + let display = reasoning + .as_ref() + .and_then(|r| r.effort.as_ref()) + .map(|e| e.to_string()) + .unwrap_or_else(|| "none".to_string()); + + self.writeln_title( + TitleFormat::action(&format!("{} reasoning", model_id.as_str())) + .sub_title(&format!("set to {}", display)), + )?; + } } Ok(()) @@ -3158,11 +3187,10 @@ impl A + Send + Sync> UI { /// Handle config get command async fn handle_config_get(&mut self, args: crate::cli::ConfigGetArgs) -> Result<()> { - use crate::cli::ConfigField; + use crate::cli::ConfigGetField; - // Get specific field match args.field { - ConfigField::Model => { + ConfigGetField::Model => { let model = self .api .get_default_model() @@ -3173,18 +3201,33 @@ impl A + Send + Sync> UI { None => self.writeln("Model: Not set")?, } } - ConfigField::Provider => { + ConfigGetField::Provider => { let provider = self .api .get_default_provider() .await .ok() - .map(|p| p.id.to_string()); + .map(|p| p.id.as_ref().to_string()); match provider { - Some(v) => self.writeln(v.to_string())?, + Some(v) => self.writeln(v)?, None => self.writeln("Provider: Not set")?, } } + ConfigGetField::ModelReasoning { provider, model } => { + let provider_id = + ProviderId::from_str(&provider).expect("from_str is infallible"); + let model_id = ModelId::new(&model); + let reasoning = self + .api + .get_model_reasoning(&provider_id, &model_id) + .await?; + let display = reasoning + .as_ref() + .and_then(|r| r.effort.as_ref()) + .map(|e| e.to_string()) + .unwrap_or_else(|| "none".to_string()); + self.writeln(display)?; + } } Ok(()) diff --git a/crates/forge_services/src/app_config.rs b/crates/forge_services/src/app_config.rs index 93e5d13660..f7fe4f0b3b 100644 --- a/crates/forge_services/src/app_config.rs +++ b/crates/forge_services/src/app_config.rs @@ -1,7 +1,9 @@ use std::sync::Arc; use forge_app::AppConfigService; -use forge_domain::{AppConfig, AppConfigRepository, ModelId, ProviderId, ProviderRepository}; +use forge_domain::{ + AppConfig, AppConfigRepository, ModelConfig, ModelId, ProviderId, ProviderRepository, +}; /// Service for managing user preferences for default providers and models. pub struct ForgeAppConfigService { @@ -80,6 +82,55 @@ impl AppConfigService }) .await } + + async fn set_model_reasoning( + &self, + provider_id: ProviderId, + model_id: ModelId, + reasoning: Option, + ) -> anyhow::Result<()> { + self.update(|config| { + match reasoning { + Some(r) => { + config + .provider_config + .entry(provider_id.clone()) + .or_default() + .entry(model_id) + .or_insert_with(ModelConfig::default) + .reasoning = Some(r); + } + None => { + // Remove reasoning config; clean up empty entries + if let Some(models) = config.provider_config.get_mut(&provider_id) { + if let Some(mc) = models.get_mut(&model_id) { + mc.reasoning = None; + if mc == &ModelConfig::default() { + models.remove(&model_id); + } + } + if models.is_empty() { + config.provider_config.remove(&provider_id); + } + } + } + } + }) + .await + } + + async fn get_model_reasoning( + &self, + provider_id: &ProviderId, + model_id: &ModelId, + ) -> anyhow::Result> { + let config = self.infra.get_app_config().await?; + Ok(config + .provider_config + .get(provider_id) + .and_then(|models| models.get(model_id)) + .and_then(|mc| mc.reasoning.clone())) + } } #[cfg(test)] diff --git a/shell-plugin/lib/actions/config.zsh b/shell-plugin/lib/actions/config.zsh index c7f71f73c5..2d2a40f432 100644 --- a/shell-plugin/lib/actions/config.zsh +++ b/shell-plugin/lib/actions/config.zsh @@ -231,6 +231,59 @@ function _forge_action_tools() { _forge_exec list tools "$agent_id" } +# Action handler: Set model reasoning effort for the current model/provider +# Fetches the active provider and model, then sets reasoning for that pair +# Usage: :model-reasoning [low|medium|high|none] +function _forge_action_model_reasoning() { + local input_text="$1" + echo + + # Resolve current provider and model + local current_provider + current_provider=$($_FORGE_BIN config get provider --porcelain 2>/dev/null) + if [[ -z "$current_provider" ]]; then + echo "No provider configured. Use :provider to set one first." + return 1 + fi + + local current_model + current_model=$($_FORGE_BIN config get model --porcelain 2>/dev/null) + if [[ -z "$current_model" ]]; then + echo "No model configured. Use :model to set one first." + return 1 + fi + + # If an effort value is provided directly, apply it + if [[ -n "$input_text" ]]; then + _forge_exec config set model-reasoning "$current_provider" "$current_model" "$input_text" + return 0 + fi + + # Get current reasoning effort for display + local current_reasoning + current_reasoning=$($_FORGE_BIN config get model-reasoning "$current_provider" "$current_model" --porcelain 2>/dev/null) + + # Build selection list with fzf + local options + options=$(printf 'EFFORT\nnone\nlow\nmedium\nhigh') + + local fzf_args=( + --prompt="Reasoning ❯ " + ) + + if [[ -n "$current_reasoning" ]]; then + local index=$(_forge_find_index "$options" "$current_reasoning") + fzf_args+=(--bind="start:pos($index)") + fi + + local selected + selected=$(echo "$options" | _forge_fzf --header-lines=1 "${fzf_args[@]}") + + if [[ -n "$selected" ]]; then + _forge_exec config set model-reasoning "$current_provider" "$current_model" "$selected" + fi +} + # Action handler: Show skills function _forge_action_skill() { echo diff --git a/shell-plugin/lib/dispatcher.zsh b/shell-plugin/lib/dispatcher.zsh index 70703379e6..a3f4098e80 100644 --- a/shell-plugin/lib/dispatcher.zsh +++ b/shell-plugin/lib/dispatcher.zsh @@ -166,6 +166,9 @@ function forge-accept-line() { model|m) _forge_action_model "$input_text" ;; + model-reasoning|mr) + _forge_action_model_reasoning "$input_text" + ;; tools|t) _forge_action_tools ;; From ad05ea4b90058beae25d089db79f202d82ae6a20 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 10:27:01 +0000 Subject: [PATCH 02/12] [autofix.ci] apply automated fixes --- crates/forge_api/src/forge_api.rs | 7 ++++--- crates/forge_main/src/ui.rs | 26 ++++++++++++-------------- 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/crates/forge_api/src/forge_api.rs b/crates/forge_api/src/forge_api.rs index ac32d99bb0..11d66193ef 100644 --- a/crates/forge_api/src/forge_api.rs +++ b/crates/forge_api/src/forge_api.rs @@ -299,8 +299,7 @@ impl< model_id: ModelId, reasoning: Option, ) -> anyhow::Result<()> { - self - .services + self.services .set_model_reasoning(provider_id, model_id, reasoning) .await } @@ -310,7 +309,9 @@ impl< provider_id: &ProviderId, model_id: &ModelId, ) -> anyhow::Result> { - self.services.get_model_reasoning(provider_id, model_id).await + self.services + .get_model_reasoning(provider_id, model_id) + .await } async fn get_login_info(&self) -> Result> { diff --git a/crates/forge_main/src/ui.rs b/crates/forge_main/src/ui.rs index cd3d00106e..9d4a88acec 100644 --- a/crates/forge_main/src/ui.rs +++ b/crates/forge_main/src/ui.rs @@ -3134,8 +3134,7 @@ impl A + Send + Sync> UI { match args.field { ConfigSetField::Provider { provider } => { - let provider_id = - ProviderId::from_str(&provider).expect("from_str is infallible"); + let provider_id = ProviderId::from_str(&provider).expect("from_str is infallible"); let provider = self.api.get_provider(&provider_id).await?; self.activate_provider(provider).await?; @@ -3148,8 +3147,7 @@ impl A + Send + Sync> UI { )?; } ConfigSetField::ModelReasoning { provider, model, effort } => { - let provider_id = - ProviderId::from_str(&provider).expect("from_str is infallible"); + let provider_id = ProviderId::from_str(&provider).expect("from_str is infallible"); let model_id = ModelId::new(&model); let value_lower = effort.to_lowercase(); @@ -3157,12 +3155,13 @@ impl A + Send + Sync> UI { None } else { use std::str::FromStr; - let effort = forge_domain::Effort::from_str(&value_lower) - .map_err(|_| anyhow::anyhow!("Invalid reasoning effort '{}'. Valid values: low, medium, high, none", effort))?; - Some( - forge_domain::ReasoningConfig::default() - .effort(effort.clone()), - ) + let effort = forge_domain::Effort::from_str(&value_lower).map_err(|_| { + anyhow::anyhow!( + "Invalid reasoning effort '{}'. Valid values: low, medium, high, none", + effort + ) + })?; + Some(forge_domain::ReasoningConfig::default().effort(effort.clone())) }; self.api @@ -3176,8 +3175,8 @@ impl A + Send + Sync> UI { .unwrap_or_else(|| "none".to_string()); self.writeln_title( - TitleFormat::action(&format!("{} reasoning", model_id.as_str())) - .sub_title(&format!("set to {}", display)), + TitleFormat::action(format!("{} reasoning", model_id.as_str())) + .sub_title(format!("set to {}", display)), )?; } } @@ -3214,8 +3213,7 @@ impl A + Send + Sync> UI { } } ConfigGetField::ModelReasoning { provider, model } => { - let provider_id = - ProviderId::from_str(&provider).expect("from_str is infallible"); + let provider_id = ProviderId::from_str(&provider).expect("from_str is infallible"); let model_id = ModelId::new(&model); let reasoning = self .api From 73ec7b8de7906b9fe636ef196a9d706aee67b5e0 Mon Sep 17 00:00:00 2001 From: laststylebender14 Date: Mon, 9 Mar 2026 15:59:46 +0530 Subject: [PATCH 03/12] fix(ui): explicitly store disabled reasoning state for providers --- crates/forge_main/src/ui.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/crates/forge_main/src/ui.rs b/crates/forge_main/src/ui.rs index 9d4a88acec..41e8f53d53 100644 --- a/crates/forge_main/src/ui.rs +++ b/crates/forge_main/src/ui.rs @@ -3152,7 +3152,9 @@ impl A + Send + Sync> UI { let value_lower = effort.to_lowercase(); let reasoning = if value_lower == "none" || value_lower == "off" { - None + // Store disabled state explicitly so providers know to turn + // off reasoning rather than leaving it at the model default. + Some(forge_domain::ReasoningConfig::default().enabled(false)) } else { use std::str::FromStr; let effort = forge_domain::Effort::from_str(&value_lower).map_err(|_| { @@ -3170,7 +3172,13 @@ impl A + Send + Sync> UI { let display = reasoning .as_ref() - .and_then(|r| r.effort.as_ref()) + .and_then(|r| { + if r.enabled == Some(false) { + None + } else { + r.effort.as_ref() + } + }) .map(|e| e.to_string()) .unwrap_or_else(|| "none".to_string()); From abdb393eeac5d97ef6d567bf157d8bd7b3d563c3 Mon Sep 17 00:00:00 2001 From: laststylebender14 Date: Mon, 9 Mar 2026 16:23:16 +0530 Subject: [PATCH 04/12] refactor(app_config): simplify reasoning config storage structure --- crates/forge_domain/src/app_config.rs | 12 ++------- crates/forge_services/src/app_config.rs | 33 +++++++++++-------------- 2 files changed, 16 insertions(+), 29 deletions(-) diff --git a/crates/forge_domain/src/app_config.rs b/crates/forge_domain/src/app_config.rs index 4244bb5619..39362ce398 100644 --- a/crates/forge_domain/src/app_config.rs +++ b/crates/forge_domain/src/app_config.rs @@ -3,7 +3,7 @@ use std::collections::HashMap; use derive_more::From; use serde::{Deserialize, Serialize}; -use crate::{ModelId, ProviderId, ReasoningConfig}; +use crate::{Effort, ModelId, ProviderId}; #[derive(Deserialize)] #[serde(rename_all = "camelCase")] @@ -13,14 +13,6 @@ pub struct InitAuth { pub token: String, } -/// Per-model configuration that can be set at runtime. -#[derive(Default, Clone, Serialize, Deserialize, Debug, PartialEq)] -pub struct ModelConfig { - /// Reasoning configuration for this specific model. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub reasoning: Option, -} - #[derive(Default, Clone, Serialize, Deserialize, Debug, PartialEq)] #[serde(rename_all = "camelCase")] pub struct AppConfig { @@ -30,7 +22,7 @@ pub struct AppConfig { #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub model: HashMap, #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub provider_config: HashMap>, + pub reasoning: HashMap>, } #[derive(Clone, Serialize, Deserialize, From, Debug, PartialEq)] diff --git a/crates/forge_services/src/app_config.rs b/crates/forge_services/src/app_config.rs index f7fe4f0b3b..fd46dac679 100644 --- a/crates/forge_services/src/app_config.rs +++ b/crates/forge_services/src/app_config.rs @@ -1,9 +1,7 @@ use std::sync::Arc; use forge_app::AppConfigService; -use forge_domain::{ - AppConfig, AppConfigRepository, ModelConfig, ModelId, ProviderId, ProviderRepository, -}; +use forge_domain::{AppConfig, AppConfigRepository, ModelId, ProviderId, ProviderRepository}; /// Service for managing user preferences for default providers and models. pub struct ForgeAppConfigService { @@ -90,27 +88,21 @@ impl AppConfigService reasoning: Option, ) -> anyhow::Result<()> { self.update(|config| { - match reasoning { - Some(r) => { + let effort = reasoning.as_ref().and_then(|r| r.effort.clone()); + match effort { + Some(e) => { config - .provider_config + .reasoning .entry(provider_id.clone()) .or_default() - .entry(model_id) - .or_insert_with(ModelConfig::default) - .reasoning = Some(r); + .insert(model_id, e); } None => { // Remove reasoning config; clean up empty entries - if let Some(models) = config.provider_config.get_mut(&provider_id) { - if let Some(mc) = models.get_mut(&model_id) { - mc.reasoning = None; - if mc == &ModelConfig::default() { - models.remove(&model_id); - } - } + if let Some(models) = config.reasoning.get_mut(&provider_id) { + models.remove(&model_id); if models.is_empty() { - config.provider_config.remove(&provider_id); + config.reasoning.remove(&provider_id); } } } @@ -126,10 +118,13 @@ impl AppConfigService ) -> anyhow::Result> { let config = self.infra.get_app_config().await?; Ok(config - .provider_config + .reasoning .get(provider_id) .and_then(|models| models.get(model_id)) - .and_then(|mc| mc.reasoning.clone())) + .map(|effort| forge_domain::ReasoningConfig { + effort: Some(effort.clone()), + ..Default::default() + })) } } From 0c056ff68babea9f61329c90c68d2999b43f36b1 Mon Sep 17 00:00:00 2001 From: laststylebender14 Date: Mon, 9 Mar 2026 16:39:14 +0530 Subject: [PATCH 05/12] feat: enhance reasoning model with 'None' effort option and update related logic --- crates/forge_domain/src/agent_definition.rs | 16 ++++++++++-- crates/forge_domain/src/context.rs | 5 ++-- crates/forge_main/src/ui.rs | 25 +++++++------------ .../src/conversation/conversation_record.rs | 3 +++ .../src/provider/openai_responses/request.rs | 1 + 5 files changed, 29 insertions(+), 21 deletions(-) diff --git a/crates/forge_domain/src/agent_definition.rs b/crates/forge_domain/src/agent_definition.rs index eb52318195..64b7724e0e 100644 --- a/crates/forge_domain/src/agent_definition.rs +++ b/crates/forge_domain/src/agent_definition.rs @@ -5,7 +5,7 @@ use derive_setters::Setters; use merge::Merge; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use strum_macros::{Display as StrumDisplay, EnumString}; +use strum_macros::{Display as StrumDisplay, EnumString, VariantNames}; use crate::compact::Compact; use crate::temperature::Temperature; @@ -194,6 +194,7 @@ pub struct ReasoningConfig { /// Controls the effort level of the agent's reasoning /// supported by openrouter and forge provider #[serde(skip_serializing_if = "Option::is_none")] + #[setters(skip)] pub effort: Option, /// Controls how many tokens the model can spend thinking. @@ -213,13 +214,24 @@ pub struct ReasoningConfig { pub enabled: Option, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, StrumDisplay, EnumString)] +impl ReasoningConfig { + pub fn effort(mut self, effort: Effort) -> Self { + if matches!(effort, Effort::None) { + self.enabled = Some(false); + } + self.effort = Some(effort); + self + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, StrumDisplay, EnumString, VariantNames)] #[serde(rename_all = "lowercase")] #[strum(serialize_all = "lowercase")] pub enum Effort { High, Medium, Low, + None, } /// Converts a thinking budget (max_tokens) to Effort diff --git a/crates/forge_domain/src/context.rs b/crates/forge_domain/src/context.rs index 66d1b201e4..88ed7d7df0 100644 --- a/crates/forge_domain/src/context.rs +++ b/crates/forge_domain/src/context.rs @@ -18,8 +18,7 @@ use crate::temperature::Temperature; use crate::top_k::TopK; use crate::top_p::TopP; use crate::{ - Attachment, AttachmentContent, ConversationId, EventValue, Image, ModelId, ReasoningFull, - ToolChoice, ToolDefinition, ToolOutput, ToolValue, Usage, + Attachment, AttachmentContent, ConversationId, Effort, EventValue, Image, ModelId, ReasoningFull, ToolChoice, ToolDefinition, ToolOutput, ToolValue, Usage }; /// Response format for structured output @@ -605,7 +604,7 @@ impl Context { pub fn is_reasoning_supported(&self) -> bool { self.reasoning.as_ref().is_some_and(|reasoning| { // When enabled parameter is defined then return it's value directly. - if reasoning.enabled.is_some() { + if reasoning.enabled.is_some() || matches!(reasoning.effort, Some(Effort::None)) { return reasoning.enabled.unwrap_or_default(); } diff --git a/crates/forge_main/src/ui.rs b/crates/forge_main/src/ui.rs index 41e8f53d53..77ba2bc8ae 100644 --- a/crates/forge_main/src/ui.rs +++ b/crates/forge_main/src/ui.rs @@ -3149,23 +3149,16 @@ impl A + Send + Sync> UI { ConfigSetField::ModelReasoning { provider, model, effort } => { let provider_id = ProviderId::from_str(&provider).expect("from_str is infallible"); let model_id = ModelId::new(&model); - let value_lower = effort.to_lowercase(); - let reasoning = if value_lower == "none" || value_lower == "off" { - // Store disabled state explicitly so providers know to turn - // off reasoning rather than leaving it at the model default. - Some(forge_domain::ReasoningConfig::default().enabled(false)) - } else { - use std::str::FromStr; - let effort = forge_domain::Effort::from_str(&value_lower).map_err(|_| { - anyhow::anyhow!( - "Invalid reasoning effort '{}'. Valid values: low, medium, high, none", - effort - ) - })?; - Some(forge_domain::ReasoningConfig::default().effort(effort.clone())) - }; - + let effort = forge_domain::Effort::from_str(&value_lower).map_err(|_| { + use strum::VariantNames; + anyhow::anyhow!( + "Invalid reasoning effort '{}'. Valid values: {}", + effort, + forge_domain::Effort::VARIANTS.join(", ") + ) + })?; + let reasoning = Some(forge_domain::ReasoningConfig::default().effort(effort)); self.api .set_model_reasoning(provider_id, model_id.clone(), reasoning.clone()) .await?; diff --git a/crates/forge_repo/src/conversation/conversation_record.rs b/crates/forge_repo/src/conversation/conversation_record.rs index d442078cc4..042007729b 100644 --- a/crates/forge_repo/src/conversation/conversation_record.rs +++ b/crates/forge_repo/src/conversation/conversation_record.rs @@ -649,6 +649,7 @@ pub(super) enum EffortRecord { High, Medium, Low, + None, } impl From<&forge_domain::Effort> for EffortRecord { @@ -657,6 +658,7 @@ impl From<&forge_domain::Effort> for EffortRecord { forge_domain::Effort::High => Self::High, forge_domain::Effort::Medium => Self::Medium, forge_domain::Effort::Low => Self::Low, + forge_domain::Effort::None => Self::None, } } } @@ -667,6 +669,7 @@ impl From for forge_domain::Effort { EffortRecord::High => Self::High, EffortRecord::Medium => Self::Medium, EffortRecord::Low => Self::Low, + EffortRecord::None => Self::None, } } } diff --git a/crates/forge_repo/src/provider/openai_responses/request.rs b/crates/forge_repo/src/provider/openai_responses/request.rs index f9f4122e96..a6c877abd6 100644 --- a/crates/forge_repo/src/provider/openai_responses/request.rs +++ b/crates/forge_repo/src/provider/openai_responses/request.rs @@ -108,6 +108,7 @@ impl FromDomain for oai::Reasoning { Effort::High => oai::ReasoningEffort::High, Effort::Medium => oai::ReasoningEffort::Medium, Effort::Low => oai::ReasoningEffort::Low, + Effort::None => oai::ReasoningEffort::None, }; builder.effort(oai_effort); } else if config.enabled.unwrap_or(false) { From adde673a59184c93fadcc27ddd2f9a560a160d45 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 11:10:56 +0000 Subject: [PATCH 06/12] [autofix.ci] apply automated fixes --- crates/forge_domain/src/agent_definition.rs | 12 +++++++++++- crates/forge_domain/src/context.rs | 3 ++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/crates/forge_domain/src/agent_definition.rs b/crates/forge_domain/src/agent_definition.rs index 64b7724e0e..70db41228b 100644 --- a/crates/forge_domain/src/agent_definition.rs +++ b/crates/forge_domain/src/agent_definition.rs @@ -224,7 +224,17 @@ impl ReasoningConfig { } } -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, StrumDisplay, EnumString, VariantNames)] +#[derive( + Debug, + Clone, + Serialize, + Deserialize, + JsonSchema, + PartialEq, + StrumDisplay, + EnumString, + VariantNames, +)] #[serde(rename_all = "lowercase")] #[strum(serialize_all = "lowercase")] pub enum Effort { diff --git a/crates/forge_domain/src/context.rs b/crates/forge_domain/src/context.rs index 88ed7d7df0..9d763c30a2 100644 --- a/crates/forge_domain/src/context.rs +++ b/crates/forge_domain/src/context.rs @@ -18,7 +18,8 @@ use crate::temperature::Temperature; use crate::top_k::TopK; use crate::top_p::TopP; use crate::{ - Attachment, AttachmentContent, ConversationId, Effort, EventValue, Image, ModelId, ReasoningFull, ToolChoice, ToolDefinition, ToolOutput, ToolValue, Usage + Attachment, AttachmentContent, ConversationId, Effort, EventValue, Image, ModelId, + ReasoningFull, ToolChoice, ToolDefinition, ToolOutput, ToolValue, Usage, }; /// Response format for structured output From f4c89588e6fcf075522493763d57f9b99d353d56 Mon Sep 17 00:00:00 2001 From: laststylebender14 Date: Mon, 9 Mar 2026 16:48:02 +0530 Subject: [PATCH 07/12] feat(transformer): update DropReasoningDetails to disable reasoning instead of setting it to None --- .../forge_domain/src/transformer/drop_reasoning_details.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/forge_domain/src/transformer/drop_reasoning_details.rs b/crates/forge_domain/src/transformer/drop_reasoning_details.rs index e6a016feb7..c46cbcad0c 100644 --- a/crates/forge_domain/src/transformer/drop_reasoning_details.rs +++ b/crates/forge_domain/src/transformer/drop_reasoning_details.rs @@ -1,4 +1,4 @@ -use crate::{Context, Transformer}; +use crate::{Context, ReasoningConfig, Transformer}; #[derive(Default)] pub struct DropReasoningDetails; @@ -12,8 +12,8 @@ impl Transformer for DropReasoningDetails { } }); - // Drop reasoning configuration - context.reasoning = None; + // Since this transformer disables reasoning completely, then we should set enabled to false instead setting the base object to None. + context.reasoning = Some(ReasoningConfig::default().enabled(false)); context } From b2d80e98b7d681a105f458b4797622cbdcd59c33 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 11:19:47 +0000 Subject: [PATCH 08/12] [autofix.ci] apply automated fixes --- crates/forge_domain/src/transformer/drop_reasoning_details.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/forge_domain/src/transformer/drop_reasoning_details.rs b/crates/forge_domain/src/transformer/drop_reasoning_details.rs index c46cbcad0c..8398dba3dd 100644 --- a/crates/forge_domain/src/transformer/drop_reasoning_details.rs +++ b/crates/forge_domain/src/transformer/drop_reasoning_details.rs @@ -12,7 +12,8 @@ impl Transformer for DropReasoningDetails { } }); - // Since this transformer disables reasoning completely, then we should set enabled to false instead setting the base object to None. + // Since this transformer disables reasoning completely, then we should set + // enabled to false instead setting the base object to None. context.reasoning = Some(ReasoningConfig::default().enabled(false)); context From c18b9bfd52aec3608116241f524a4610f35f22bf Mon Sep 17 00:00:00 2001 From: laststylebender14 Date: Mon, 9 Mar 2026 16:49:48 +0530 Subject: [PATCH 09/12] feat: disable reasoning directly when effort is set to None in ReasoningConfig --- crates/forge_domain/src/agent_definition.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/forge_domain/src/agent_definition.rs b/crates/forge_domain/src/agent_definition.rs index 70db41228b..9fc2040e66 100644 --- a/crates/forge_domain/src/agent_definition.rs +++ b/crates/forge_domain/src/agent_definition.rs @@ -216,6 +216,7 @@ pub struct ReasoningConfig { impl ReasoningConfig { pub fn effort(mut self, effort: Effort) -> Self { + // if effort is set to None, then disable reasoning directly. if matches!(effort, Effort::None) { self.enabled = Some(false); } From 548015657772948720d1a822e848b2596a4b95c0 Mon Sep 17 00:00:00 2001 From: laststylebender14 Date: Mon, 9 Mar 2026 17:40:14 +0530 Subject: [PATCH 10/12] - used types instead of string --- crates/forge_main/src/cli.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/crates/forge_main/src/cli.rs b/crates/forge_main/src/cli.rs index d9fd256cb9..a1d9021afb 100644 --- a/crates/forge_main/src/cli.rs +++ b/crates/forge_main/src/cli.rs @@ -8,7 +8,7 @@ use std::path::PathBuf; use clap::{Parser, Subcommand, ValueEnum}; -use forge_domain::{AgentId, ConversationId, ModelId, ProviderId}; +use forge_domain::{AgentId, ConversationId, ModelId, ProviderId, Effort}; #[derive(Parser)] #[command(version = env!("CARGO_PKG_VERSION"))] @@ -532,22 +532,22 @@ pub enum ConfigSetField { /// Set the active model. Model { /// Model ID to set as default. - model: String, + model: ModelId, }, /// Set the active provider. Provider { /// Provider ID to set as default. - provider: String, + provider: ProviderId, }, /// Set reasoning effort for a specific provider and model. #[command(name = "model-reasoning")] ModelReasoning { /// Provider ID. - provider: String, + provider: ProviderId, /// Model ID. - model: String, + model: ModelId, /// Reasoning effort (low, medium, high, or "none" to clear). - effort: String, + effort: Effort, }, } @@ -569,9 +569,9 @@ pub enum ConfigGetField { #[command(name = "model-reasoning")] ModelReasoning { /// Provider ID. - provider: String, + provider: ProviderId, /// Model ID. - model: String, + model: ModelId, }, } From 919f566ca10922dae61f48141833017e26d118e5 Mon Sep 17 00:00:00 2001 From: laststylebender14 Date: Mon, 9 Mar 2026 17:42:45 +0530 Subject: [PATCH 11/12] refactor(ui): use strings directly instead of converting to IDs --- crates/forge_main/src/ui.rs | 25 +++++-------------------- 1 file changed, 5 insertions(+), 20 deletions(-) diff --git a/crates/forge_main/src/ui.rs b/crates/forge_main/src/ui.rs index 77ba2bc8ae..bb92e81890 100644 --- a/crates/forge_main/src/ui.rs +++ b/crates/forge_main/src/ui.rs @@ -3134,33 +3134,20 @@ impl A + Send + Sync> UI { match args.field { ConfigSetField::Provider { provider } => { - let provider_id = ProviderId::from_str(&provider).expect("from_str is infallible"); - - let provider = self.api.get_provider(&provider_id).await?; + let provider = self.api.get_provider(&provider).await?; self.activate_provider(provider).await?; } ConfigSetField::Model { model } => { - let model_id = self.validate_model(&model).await?; + let model_id = self.validate_model(model.as_str()).await?; self.api.set_default_model(model_id.clone()).await?; self.writeln_title( TitleFormat::action(model_id.as_str()).sub_title("is now the default model"), )?; } - ConfigSetField::ModelReasoning { provider, model, effort } => { - let provider_id = ProviderId::from_str(&provider).expect("from_str is infallible"); - let model_id = ModelId::new(&model); - let value_lower = effort.to_lowercase(); - let effort = forge_domain::Effort::from_str(&value_lower).map_err(|_| { - use strum::VariantNames; - anyhow::anyhow!( - "Invalid reasoning effort '{}'. Valid values: {}", - effort, - forge_domain::Effort::VARIANTS.join(", ") - ) - })?; + ConfigSetField::ModelReasoning { provider, model: model_id, effort } => { let reasoning = Some(forge_domain::ReasoningConfig::default().effort(effort)); self.api - .set_model_reasoning(provider_id, model_id.clone(), reasoning.clone()) + .set_model_reasoning(provider, model_id.clone(), reasoning.clone()) .await?; let display = reasoning @@ -3214,11 +3201,9 @@ impl A + Send + Sync> UI { } } ConfigGetField::ModelReasoning { provider, model } => { - let provider_id = ProviderId::from_str(&provider).expect("from_str is infallible"); - let model_id = ModelId::new(&model); let reasoning = self .api - .get_model_reasoning(&provider_id, &model_id) + .get_model_reasoning(&provider, &model) .await?; let display = reasoning .as_ref() From 47f95369a82fe5dc99082c06920935a13f120e63 Mon Sep 17 00:00:00 2001 From: laststylebender14 Date: Mon, 9 Mar 2026 19:05:50 +0530 Subject: [PATCH 12/12] fix(action): log error messages for missing provider and model configurations --- shell-plugin/lib/actions/config.zsh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/shell-plugin/lib/actions/config.zsh b/shell-plugin/lib/actions/config.zsh index 2d2a40f432..5f2afdae30 100644 --- a/shell-plugin/lib/actions/config.zsh +++ b/shell-plugin/lib/actions/config.zsh @@ -242,14 +242,14 @@ function _forge_action_model_reasoning() { local current_provider current_provider=$($_FORGE_BIN config get provider --porcelain 2>/dev/null) if [[ -z "$current_provider" ]]; then - echo "No provider configured. Use :provider to set one first." + _forge_log error "No provider configured. Use :provider to set one first." return 1 fi local current_model current_model=$($_FORGE_BIN config get model --porcelain 2>/dev/null) if [[ -z "$current_model" ]]; then - echo "No model configured. Use :model to set one first." + _forge_log error "No model configured. Use :model to set one first." return 1 fi