Separate model reasoning selections and reduce agent notification noise

This commit is contained in:
2026-10-25 01:31:39 -05:00
parent 55eb87a983
commit 596b7266a4
19 changed files with 829 additions and 138 deletions
@@ -249,7 +249,7 @@ impl AgentNotificationsModel {
terminal_surface_id,
conversation_id,
// We shouldn't trigger toasts when restoring conversations on startup.
update: ConversationStatusUpdate::Changed { .. },
update: ConversationStatusUpdate::Changed { prev_status },
..
} = event
else {
@@ -266,6 +266,21 @@ impl AgentNotificationsModel {
}
let status = updated_conversation.status().clone();
if &status == prev_status {
return;
}
// Workers report their results to the orchestrator. Only a worker that
// needs user input should interrupt the user independently.
if updated_conversation.is_child_agent_conversation()
&& !status.should_notify_for_agent(true)
{
self.pending_artifacts.remove(conversation_id);
self.remove_notification_by_source(
NotificationOrigin::Conversation(*conversation_id),
ctx,
);
return;
}
let latest_query = updated_conversation.latest_user_query();
if FeatureFlag::HOANotifications.is_enabled() {
self.handle_history_event_for_mailbox(
@@ -283,6 +298,12 @@ impl AgentNotificationsModel {
return;
}
if matches!(status, ConversationStatus::Success)
&& QueuedQueryModel::as_ref(ctx).has_autofireable_prompt(*conversation_id)
{
return;
}
if is_terminal_view_visible(*terminal_surface_id, ctx) {
return;
}
@@ -354,18 +375,10 @@ impl AgentNotificationsModel {
);
}
ConversationStatus::Cancelled => {
let artifacts = self.flush_pending_artifacts(conversation_id);
self.add_notification(
title,
"Task was cancelled.".to_owned(),
NotificationCategory::Complete,
oz_agent,
origin,
terminal_view_id,
artifacts,
metadata.branch,
ctx,
);
// Steering and replacing a turn cancels the old generation;
// that is not a separate task-completion notification.
self.pending_artifacts.remove(&conversation_id);
self.remove_notification_by_source(origin, ctx);
}
ConversationStatus::Blocked { blocked_action } => {
self.add_notification(
@@ -484,6 +497,10 @@ pub enum AgentManagementEvent {
}
impl ConversationStatus {
pub(crate) fn should_notify_for_agent(&self, is_child: bool) -> bool {
self.should_trigger_notification() && (!is_child || self.is_blocked())
}
/// Returns true if the updating the conversation with this status should trigger some
/// notification to the user.
///
@@ -297,6 +297,36 @@ fn should_trigger_notification_returns_false_for_cancelled() {
assert!(!ConversationStatus::Cancelled.should_trigger_notification());
}
#[test]
fn worker_results_do_not_interrupt_the_orchestrator() {
for status in [ConversationStatus::Success, ConversationStatus::Error] {
assert!(!status.should_notify_for_agent(true));
assert!(status.should_notify_for_agent(false));
}
}
#[test]
fn worker_permission_requests_still_notify() {
let status = ConversationStatus::Blocked {
blocked_action: "Approve deployment".to_owned(),
};
assert!(status.should_notify_for_agent(true));
assert!(status.should_notify_for_agent(false));
}
#[test]
fn running_recovering_and_cancelled_agents_do_not_notify() {
for status in [
ConversationStatus::InProgress,
ConversationStatus::TransientError,
ConversationStatus::WaitingForEvents,
ConversationStatus::Cancelled,
] {
assert!(!status.should_notify_for_agent(true));
assert!(!status.should_notify_for_agent(false));
}
}
// Mailbox suppression for non-terminal status updates. In App::test the
// `is_conversation_open` gate always returns false, so the
// WaitingForEvents and InProgress arms both clear stale notifications
+5 -1
View File
@@ -268,7 +268,7 @@ impl CrosscheckReviewer {
let effective_model = config.model.unwrap_or(model_id);
let request_body = serde_json::json!({
let mut request_body = serde_json::json!({
"model": effective_model,
"messages": [
{
@@ -286,6 +286,10 @@ impl CrosscheckReviewer {
"stream": false
});
if let Some(effort) = config.reasoning_effort {
request_body["reasoning_effort"] = effort.into();
}
// Use a non-streaming request by calling the same endpoint but with stream: false
let url = format!("{}/chat/completions", config.base_url.trim_end_matches('/'));
+99 -6
View File
@@ -19,7 +19,9 @@ use warpui::{
};
use crate::ai::blocklist::BlocklistAIPermissions;
use crate::ai::execution_profiles::model_menu_items::available_model_menu_items;
use crate::ai::execution_profiles::model_menu_items::{
available_model_menu_items, collapse_model_reasoning_choices, reasoning_choices_for_model,
};
use crate::ai::execution_profiles::profiles::{
AIExecutionProfilesModel, AIExecutionProfilesModelEvent, ClientProfileId,
};
@@ -34,6 +36,7 @@ use crate::cloud_object::model::generic_string_model::StringModel;
use crate::editor::{
EditorView, Event as EditorEvent, InteractionState, SingleLineEditorOptions, TextOptions,
};
use crate::menu::{MenuItem, MenuItemFields, MenuTooltipPosition};
use crate::pane_group::focus_state::PaneFocusHandle;
use crate::pane_group::pane::view;
use crate::pane_group::{BackingView, PaneConfiguration, PaneEvent};
@@ -132,6 +135,13 @@ pub enum ExecutionProfileEditorViewEvent {
Pane(PaneEvent),
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ProfileModelRole {
Base,
FullTerminalUse,
ComputerUse,
}
#[derive(Debug, Clone, PartialEq)]
pub enum ExecutionProfileEditorViewAction {
Save,
@@ -139,6 +149,10 @@ pub enum ExecutionProfileEditorViewAction {
SetBaseModel {
id: LLMId,
},
SetModelReasoning {
role: ProfileModelRole,
id: LLMId,
},
/// Fired continuously while the user drags the context window slider.
ContextWindowSliderDragged {
value: u32,
@@ -198,6 +212,10 @@ pub struct ExecutionProfileEditorView {
full_terminal_use_model_dropdown:
ViewHandle<FilterableDropdown<ExecutionProfileEditorViewAction>>,
computer_use_model_dropdown: ViewHandle<FilterableDropdown<ExecutionProfileEditorViewAction>>,
base_reasoning_dropdown: ViewHandle<Dropdown<ExecutionProfileEditorViewAction>>,
full_terminal_reasoning_dropdown: ViewHandle<Dropdown<ExecutionProfileEditorViewAction>>,
computer_use_reasoning_dropdown: ViewHandle<Dropdown<ExecutionProfileEditorViewAction>>,
reasoning_roles: Vec<ProfileModelRole>,
permission_mode_dropdown: ViewHandle<Dropdown<ExecutionProfileEditorViewAction>>,
command_denylist_editor: ViewHandle<SubmittableTextInput>,
command_allowlist_editor: ViewHandle<SubmittableTextInput>,
@@ -301,6 +319,9 @@ impl ExecutionProfileEditorView {
dropdown.set_menu_width(MODEL_MENU_WIDTH, ctx);
dropdown
});
let base_reasoning_dropdown = ctx.add_typed_action_view(Dropdown::new);
let full_terminal_reasoning_dropdown = ctx.add_typed_action_view(Dropdown::new);
let computer_use_reasoning_dropdown = ctx.add_typed_action_view(Dropdown::new);
let command_allowlist_editor = ctx.add_typed_action_view(|ctx| {
let mut input = SubmittableTextInput::new(ctx).validate_on_edit(|s| Regex::new(s).is_ok());
input.set_placeholder_text("e.g. ^cargo test$", ctx);
@@ -361,6 +382,10 @@ impl ExecutionProfileEditorView {
coding_model_dropdown,
full_terminal_use_model_dropdown,
computer_use_model_dropdown,
base_reasoning_dropdown,
full_terminal_reasoning_dropdown,
computer_use_reasoning_dropdown,
reasoning_roles: Vec::new(),
permission_mode_dropdown,
command_denylist_editor,
command_allowlist_editor,
@@ -482,6 +507,7 @@ impl ExecutionProfileEditorView {
);
}
}
me.refresh_reasoning_dropdowns(ctx);
});
// Refresh model dropdowns when BYO API keys update so key icons reflect current state.
@@ -506,6 +532,7 @@ impl ExecutionProfileEditorView {
ctx,
);
me.sync_context_window_editor(ctx, false);
me.refresh_reasoning_dropdowns(ctx);
ctx.notify();
},
);
@@ -647,6 +674,62 @@ impl ExecutionProfileEditorView {
Self::update_profile_name_editor(&self.profile_name_editor, &current_permissions, ctx);
self.sync_context_window_editor(ctx, false);
self.refresh_reasoning_dropdowns(ctx);
}
fn refresh_reasoning_dropdowns(&mut self, ctx: &mut ViewContext<Self>) {
let profile = BlocklistAIPermissions::as_ref(ctx)
.permissions_profile_for_id(ctx, self.profile_id);
self.reasoning_roles.clear();
for role in [ProfileModelRole::Base, ProfileModelRole::FullTerminalUse, ProfileModelRole::ComputerUse] {
let prefs = LLMPreferences::as_ref(ctx);
let (id, choices, dropdown) = match role {
ProfileModelRole::Base => (
profile.base_model.clone().unwrap_or_else(|| prefs.get_default_base_model().id.clone()),
prefs.get_base_llm_choices_for_agent_mode(ctx).collect_vec(),
&self.base_reasoning_dropdown,
),
ProfileModelRole::FullTerminalUse => (
profile.cli_agent_model.clone().unwrap_or_else(|| prefs.get_default_cli_agent_model().id.clone()),
prefs.get_cli_agent_llm_choices(ctx).collect_vec(),
&self.full_terminal_reasoning_dropdown,
),
ProfileModelRole::ComputerUse => (
profile.computer_use_model.clone().unwrap_or_else(|| prefs.get_default_computer_use_model().id.clone()),
prefs.get_computer_use_llm_choices(ctx).collect_vec(),
&self.computer_use_reasoning_dropdown,
),
};
let id = prefs.get_llm_info(&id).map_or(id.clone(), |model| model.id.clone());
let reasoning_choices = reasoning_choices_for_model(&id, &choices);
let items: Vec<MenuItem<DropdownAction>> = reasoning_choices.iter().map(|model| {
let mut fields = MenuItemFields::new(
model.reasoning_level.clone().unwrap_or_else(|| "Provider default".to_owned()),
)
.with_on_select_action(DropdownAction::select_action_and_close(
ExecutionProfileEditorViewAction::SetModelReasoning { role, id: model.id.clone() },
))
.with_disabled(model.disable_reason.is_some());
if let Some(reason) = &model.disable_reason {
fields = fields.with_tooltip(reason.tooltip_text())
.with_tooltip_position(MenuTooltipPosition::Above);
}
fields.into_item()
}).collect();
if !reasoning_choices.is_empty() {
self.reasoning_roles.push(role);
}
dropdown.update(ctx, |dropdown, ctx| {
dropdown.set_rich_items(items, ctx);
dropdown.set_selected_by_action(ExecutionProfileEditorViewAction::SetModelReasoning { role, id }, ctx);
if AISettings::as_ref(ctx).is_any_ai_enabled(ctx) {
dropdown.set_enabled(ctx);
} else {
dropdown.set_disabled(ctx);
}
});
}
ctx.notify();
}
fn refresh_filterable_model_dropdown<G, A, D>(
@@ -673,7 +756,10 @@ impl ExecutionProfileEditorView {
let llm_prefs = LLMPreferences::handle(ctx);
let llm_prefs = llm_prefs.as_ref(ctx);
let choices = get_choices(llm_prefs, ctx);
let model_to_select = profile_model.unwrap_or_else(|| get_default_id(llm_prefs));
let model_to_select = llm_prefs.get_llm_info(&model_to_select)
.map_or(model_to_select.clone(), |model| model.id.clone());
let choices = collapse_model_reasoning_choices(get_choices(llm_prefs, ctx), Some(&model_to_select));
let has_upgrade_gated_models = choices
.iter()
@@ -685,7 +771,7 @@ impl ExecutionProfileEditorView {
None,
None,
false,
false,
true,
ctx,
);
dropdown.set_rich_items(items, ctx);
@@ -700,9 +786,6 @@ impl ExecutionProfileEditorView {
dropdown.clear_footer(ctx);
}
let llm_prefs = LLMPreferences::handle(ctx);
let llm_prefs = llm_prefs.as_ref(ctx);
let model_to_select = profile_model.unwrap_or_else(|| get_default_id(llm_prefs));
dropdown.set_selected_by_action(create_action(model_to_select), ctx);
ctx.notify();
});
@@ -1036,6 +1119,16 @@ impl TypedActionView for ExecutionProfileEditorView {
self.sync_context_window_editor(ctx, true);
ctx.notify();
}
ExecutionProfileEditorViewAction::SetModelReasoning { role, id } => {
AIExecutionProfilesModel::handle(ctx).update(ctx, |profiles_model, ctx| {
match role {
ProfileModelRole::Base => profiles_model.set_base_model(self.profile_id, Some(id.clone()), ctx),
ProfileModelRole::FullTerminalUse => profiles_model.set_cli_agent_model(self.profile_id, Some(id.clone()), ctx),
ProfileModelRole::ComputerUse => profiles_model.set_computer_use_model(self.profile_id, Some(id.clone()), ctx),
}
});
ctx.notify();
}
ExecutionProfileEditorViewAction::ContextWindowSliderDragged { value } => {
if !AISettings::as_ref(ctx).is_any_ai_enabled(ctx) {
self.sync_context_window_editor(ctx, true);
@@ -5,6 +5,9 @@ use warpui::App;
use super::ui_helpers::context_window_snap_values;
use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel;
use crate::ai::execution_profiles::model_menu_items::{
collapse_model_reasoning_choices, has_reasoning_variants, reasoning_choices_for_model,
};
use crate::ai::execution_profiles::{
has_configurable_context_window, should_show_long_context_pricing_warning, AIExecutionProfile,
AIExecutionProfileAppExt as _,
@@ -316,3 +319,53 @@ fn non_openai_configurable_context_ignores_gpt_flag_and_does_not_show_openai_war
false
));
}
fn reasoning_model(id: &str, provider: &str, effort: Option<&str>) -> LLMInfo {
let mut model = configurable_model(LLMProvider::LiteLLM);
model.id = id.into();
model.base_model_name = "Same model".to_owned();
model.description = Some(provider.to_owned());
model.reasoning_level = effort.map(str::to_owned);
model
}
#[test]
fn reasoning_dropdown_supports_one_advertised_level_and_provider_default() {
let base = reasoning_model("model", "Connection A", None);
let high = reasoning_model("model::reasoning::high", "Connection A", Some("high"));
let choices = vec![&base, &high];
assert!(has_reasoning_variants(&base, &choices));
let levels = reasoning_choices_for_model(&base.id, &choices);
assert_eq!(levels.len(), 2);
assert_eq!(levels[0].reasoning_level, None);
assert_eq!(levels[1].reasoning_level.as_deref(), Some("high"));
}
#[test]
fn reasoning_choices_without_discovery_remain_unchanged() {
let base = reasoning_model("model", "Connection A", None);
let choices = vec![&base];
assert!(!has_reasoning_variants(&base, &choices));
assert!(reasoning_choices_for_model(&base.id, &choices).is_empty());
assert_eq!(collapse_model_reasoning_choices(choices, Some(&base.id))[0].id, base.id);
}
#[test]
fn collapsed_models_preserve_selected_reasoning_and_provider_identity() {
let base = reasoning_model("model", "Connection A", None);
let high = reasoning_model("model::reasoning::high", "Connection A", Some("high"));
let other = reasoning_model("model::connection::other", "Connection B", None);
let choices = collapse_model_reasoning_choices(vec![&base, &high, &other], Some(&high.id));
assert_eq!(choices.len(), 2);
assert_eq!(choices[0].id, high.id);
assert_eq!(choices[1].id, other.id);
assert!(reasoning_choices_for_model(&other.id, &[&base, &high, &other]).is_empty());
}
#[test]
fn reasoning_dropdown_does_not_invent_provider_default() {
let high = reasoning_model("model::reasoning::high", "Connection A", Some("high"));
let levels = reasoning_choices_for_model(&high.id, &[&high]);
assert_eq!(levels.len(), 1);
assert_eq!(levels[0].id, high.id);
}
@@ -12,7 +12,7 @@ use warpui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
use warpui::ui_components::text::Span;
use warpui::{AppContext, Element, SingletonEntity, ViewHandle};
use super::{ExecutionProfileEditorView, ExecutionProfileEditorViewAction};
use super::{ExecutionProfileEditorView, ExecutionProfileEditorViewAction, ProfileModelRole};
use crate::ai::blocklist::BlocklistAIPermissions;
use crate::ai::execution_profiles::{
long_context_pricing_warning_title, AIExecutionProfile, AIExecutionProfileAppExt as _,
@@ -247,6 +247,29 @@ fn render_permission_row<T: DropdownItemAction>(
.finish()
}
fn render_reasoning_dropdown_row(
appearance: &Appearance,
dropdown: &ViewHandle<Dropdown<ExecutionProfileEditorViewAction>>,
) -> Box<dyn Element> {
Container::new(
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(
Container::new(
Text::new("Reasoning level", appearance.ui_font_family(), 12.)
.with_color(appearance.theme().active_ui_text_color().into())
.finish(),
)
.with_margin_right(12.)
.finish(),
)
.with_child(ChildView::new(dropdown).finish())
.finish(),
)
.with_margin_bottom(16.)
.finish()
}
pub fn render_models_section(
appearance: &Appearance,
view: &ExecutionProfileEditorView,
@@ -262,6 +285,10 @@ pub fn render_models_section(
&view.base_model_dropdown,
));
if view.reasoning_roles.contains(&ProfileModelRole::Base) {
column.add_child(render_reasoning_dropdown_row(appearance, &view.base_reasoning_dropdown));
}
if let Some(row) = render_context_window_row(appearance, view, app) {
column.add_child(row);
}
@@ -273,6 +300,10 @@ pub fn render_models_section(
&view.full_terminal_use_model_dropdown,
));
if view.reasoning_roles.contains(&ProfileModelRole::FullTerminalUse) {
column.add_child(render_reasoning_dropdown_row(appearance, &view.full_terminal_reasoning_dropdown));
}
if FeatureFlag::LocalComputerUse.is_enabled() {
column.add_child(render_filterable_dropdown_row(
appearance,
@@ -280,6 +311,9 @@ pub fn render_models_section(
"The model used when the agent takes control of your computer to interact with graphical applications through mouse movements, clicks, and keyboard input.",
&view.computer_use_model_dropdown,
));
if view.reasoning_roles.contains(&ProfileModelRole::ComputerUse) {
column.add_child(render_reasoning_dropdown_row(appearance, &view.computer_use_reasoning_dropdown));
}
}
Container::new(column.finish())
@@ -1,3 +1,4 @@
use std::collections::HashSet;
use std::sync::Arc;
use galaxy_core::ui::Icon;
@@ -21,15 +22,55 @@ pub fn is_auto(llm: &LLMInfo) -> bool {
|| llm.id.to_string().to_lowercase().contains("auto")
}
/// Returns true if the given model has other variants with different reasoning levels.
/// Returns true when a model's configured connection advertises reasoning choices.
pub fn has_reasoning_variants(llm: &LLMInfo, all_models: &[&LLMInfo]) -> bool {
if !llm.has_reasoning_level() {
return false;
all_models.iter().any(|other| {
other.reasoning_group_key() == llm.reasoning_group_key() && other.has_reasoning_level()
})
}
/// Keep one visible model entry per connection while preserving the selected
/// reasoning variant as the entry's action. Unknown reasoning metadata stays unchanged.
pub fn collapse_model_reasoning_choices<'a>(
choices: Vec<&'a LLMInfo>,
selected_id: Option<&LLMId>,
) -> Vec<&'a LLMInfo> {
let mut groups = HashSet::new();
let mut result = Vec::new();
for model in &choices {
if !has_reasoning_variants(model, &choices) {
result.push(*model);
continue;
}
let key = model.reasoning_group_key();
if !groups.insert(key.clone()) {
continue;
}
let matching = || choices.iter().copied().filter(|other| other.reasoning_group_key() == key);
let representative = matching()
.find(|other| Some(&other.id) == selected_id)
.or_else(|| matching().find(|other| !other.has_reasoning_level()))
.unwrap_or(model);
result.push(representative);
}
all_models
.iter()
.filter(|other| other.reasoning_group_key() == llm.reasoning_group_key() && other.id != llm.id)
.any(|other| other.has_reasoning_level())
result
}
/// Includes the provider-default entry only when that entry is actually available.
pub fn reasoning_choices_for_model<'a>(
selected_id: &LLMId,
choices: &[&'a LLMInfo],
) -> Vec<&'a LLMInfo> {
let Some(selected) = choices.iter().find(|model| &model.id == selected_id) else {
return Vec::new();
};
if !has_reasoning_variants(selected, choices) {
return Vec::new();
}
choices.iter().copied()
.filter(|model| model.reasoning_group_key() == selected.reasoning_group_key())
.unique_by(|model| model.reasoning_level.clone())
.collect()
}
fn with_cost_and_profile_info<A: Action + Clone>(
+90 -32
View File
@@ -386,6 +386,8 @@ pub struct AvailableLLMs {
/// The Warp "default" LLM.
default_id: LLMId,
choices: Vec<LLMInfo>,
#[serde(skip)]
legacy_reasoning_aliases: HashMap<LLMId, LLMId>,
#[serde(default)]
preferred_codex_model_id: Option<LLMId>,
@@ -423,12 +425,16 @@ impl AvailableLLMs {
Ok(Self {
default_id,
choices: choices.into_iter().collect(),
legacy_reasoning_aliases: HashMap::new(),
preferred_codex_model_id,
})
}
fn info_for_id(&self, id: &LLMId) -> Option<&LLMInfo> {
self.choices.iter().find(|info| info.id == *id)
self.choices.iter().find(|info| info.id == *id).or_else(|| {
let resolved = self.legacy_reasoning_aliases.get(id)?;
self.choices.iter().find(|info| info.id == *resolved)
})
}
/// Returns the info for the given id only if the model is usable (present
@@ -516,6 +522,7 @@ impl ModelsByFeature {
/// Used both in `ModelsByFeature::default()` and as a fallback in `get_computer_use_available()`.
fn default_computer_use_llms() -> AvailableLLMs {
AvailableLLMs {
legacy_reasoning_aliases: HashMap::new(),
default_id: "computer-use-agent-auto".to_owned().into(),
choices: vec![LLMInfo {
display_name: "auto".to_owned(),
@@ -546,6 +553,7 @@ impl Default for ModelsByFeature {
/// `inject_bedrock_models` once real models are loaded.
fn default() -> Self {
let placeholder = || AvailableLLMs {
legacy_reasoning_aliases: HashMap::new(),
default_id: "placeholder".to_owned().into(),
choices: vec![LLMInfo {
display_name: "No models configured".to_owned(),
@@ -1131,6 +1139,11 @@ impl LLMPreferences {
cli.choices.retain(|m| m.provider != LLMProvider::LiteLLM);
}
self.openai_provider_routing.clear();
self.models_by_feature.agent_mode.legacy_reasoning_aliases.clear();
self.models_by_feature.coding.legacy_reasoning_aliases.clear();
if let Some(cli) = &mut self.models_by_feature.cli_agent {
cli.legacy_reasoning_aliases.clear();
}
let settings = AISettings::as_ref(ctx);
self.inject_acp_models(ctx);
@@ -1241,6 +1254,8 @@ impl LLMPreferences {
let mut total_injected = 0;
let mut seen_model_ids: HashSet<String> = HashSet::new();
let mut legacy_reserved_model_ids = HashSet::new();
let mut legacy_reasoning_aliases = HashMap::new();
let mut seen_connections = HashSet::new();
for (
provider_name,
@@ -1263,31 +1278,33 @@ impl LLMPreferences {
continue;
}
let reasoning_efforts: Vec<Option<&String>> =
if provider_kind == OpenAIProviderKind::ChatGPTSubscription {
// Keep the base model as the provider-default mode, then expose each
// explicitly supported effort as a separate selectable variant.
std::iter::once(None)
.chain(model.reasoning_efforts.iter().map(Some))
.collect()
} else {
vec![None]
};
// All providers may advertise discrete effort choices. Keep the
// default model key stable and route explicit choices separately.
let reasoning_efforts = std::iter::once(None)
.chain(model.reasoning_efforts.iter().map(Some));
// Choose a namespace once for the whole family. Otherwise a
// colliding base with unique efforts would split one connection
// into multiple groups in the model/reasoning selectors.
let connection_suffix = if seen_model_ids.contains(&model.model_id) {
use sha2::{Digest, Sha256};
Some(format!("{:x}", Sha256::digest(connection_identity.as_bytes())))
} else {
None
};
for reasoning_effort in reasoning_efforts {
let reasoning_effort = reasoning_effort.cloned();
let mut model_key = reasoning_effort.as_deref().map_or_else(
|| model.model_id.clone(),
|effort| openai_model_variant_id(&model.model_id, effort),
);
let legacy_key = model_key.clone();
let legacy_unclaimed = legacy_reserved_model_ids.insert(legacy_key.clone());
if let Some(suffix) = &connection_suffix {
model_key = format!("{model_key}::connection::{suffix}");
}
if !seen_model_ids.insert(model_key.clone()) {
use sha2::{Digest, Sha256};
let connection_hash =
format!("{:x}", Sha256::digest(connection_identity.as_bytes()));
model_key = format!("{model_key}::connection::{connection_hash}");
if !seen_model_ids.insert(model_key.clone()) {
continue;
}
continue;
}
// Disabled entries still reserve their selection keys so
// toggling a connection cannot silently reroute another
@@ -1296,6 +1313,9 @@ impl LLMPreferences {
continue;
}
if legacy_unclaimed && reasoning_effort.is_some() && legacy_key != model_key {
legacy_reasoning_aliases.insert(LLMId::from(legacy_key), LLMId::from(model_key.clone()));
}
// Register the routing entry. Reasoning variants keep the provider's
// actual model ID while using their synthetic key only for selection.
let client_config = OpenAIClientConfig {
@@ -1354,6 +1374,18 @@ impl LLMPreferences {
}
}
// An exact key, including one reserved by a disabled model, always wins.
legacy_reasoning_aliases.retain(|legacy_id, _| !seen_model_ids.contains(legacy_id.as_str()));
for (legacy_id, canonical_id) in &legacy_reasoning_aliases {
if let Some(config) = self.openai_provider_routing.get(canonical_id.as_str()).cloned() {
self.openai_provider_routing.entry(legacy_id.to_string()).or_insert(config);
}
}
self.models_by_feature.agent_mode.legacy_reasoning_aliases = legacy_reasoning_aliases.clone();
self.models_by_feature.coding.legacy_reasoning_aliases = legacy_reasoning_aliases.clone();
if let Some(cli) = &mut self.models_by_feature.cli_agent {
cli.legacy_reasoning_aliases = legacy_reasoning_aliases;
}
log::info!("[openai/litellm] Injected {total_injected} model(s) into available choices");
}
@@ -1998,7 +2030,7 @@ impl LLMPreferences {
provider: None,
supports_system_messages: Some(true),
capability_overrides: std::collections::HashMap::new(),
reasoning_efforts: Vec::new(),
reasoning_efforts: model.reasoning_efforts,
enabled: true,
})
.collect()
@@ -2974,6 +3006,36 @@ fn u32_from_json_any(value: &serde_json::Value, keys: &[&str]) -> Option<u32> {
.and_then(|value| u32::try_from(value).ok())
}
/// Read enumerated effort support, never infer choices from model names or a
/// generic supports_reasoning flag. Explicit lists take precedence over flags.
#[cfg(not(target_family = "wasm"))]
fn advertised_reasoning_efforts(value: &serde_json::Value) -> Vec<String> {
for key in [
"reasoning_effort_levels",
"supported_reasoning_efforts",
"reasoning_efforts",
"supported_reasoning_levels",
] {
if let Some(levels) = value[key].as_array() {
let mut result = Vec::new();
for level in levels {
if let Some(effort) = level.as_str().or_else(|| level["effort"].as_str()) {
let effort = effort.trim();
if !effort.is_empty() && !result.iter().any(|entry| entry == effort) {
result.push(effort.to_string());
}
}
}
return result;
}
}
["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"]
.into_iter()
.filter(|level| value[format!("supports_{level}_reasoning_effort")].as_bool() == Some(true))
.map(str::to_string)
.collect()
}
#[cfg(not(target_family = "wasm"))]
fn normalize_codex_release_version(version: &str) -> Option<String> {
let version = version.trim();
@@ -3125,17 +3187,7 @@ fn chatgpt_models_from_codex_response(body: &serde_json::Value) -> Vec<OpenAIMod
.any(|modality| modality.as_str() == Some("image"))
})
.unwrap_or(true);
let reasoning_efforts = model["supported_reasoning_levels"]
.as_array()
.map(|levels| {
levels
.iter()
.filter_map(|level| level["effort"].as_str())
.filter(|effort| !effort.trim().is_empty())
.map(str::to_string)
.collect::<Vec<_>>()
})
.unwrap_or_default();
let reasoning_efforts = advertised_reasoning_efforts(model);
Some(OpenAIModelConfig {
model_id: model_id.to_string(),
@@ -3283,7 +3335,7 @@ async fn fetch_from_litellm_model_info(
model_info["supports_system_messages"].as_bool()
},
capability_overrides: std::collections::HashMap::new(),
reasoning_efforts: Vec::new(),
reasoning_efforts: advertised_reasoning_efforts(model_info),
enabled: true,
})
})
@@ -3322,6 +3374,12 @@ async fn fetch_litellm_catalog(
.iter()
.find(|info| info.model_id == model.model_id)
.cloned()
.map(|mut info| {
if info.reasoning_efforts.is_empty() {
info.reasoning_efforts = model.reasoning_efforts.clone();
}
info
})
.unwrap_or(model)
})
.collect())
@@ -3442,7 +3500,7 @@ async fn fetch_from_openai_models(
m["supports_system_messages"].as_bool()
},
capability_overrides: std::collections::HashMap::new(),
reasoning_efforts: Vec::new(),
reasoning_efforts: advertised_reasoning_efforts(m),
enabled: true,
})
})
+103
View File
@@ -256,6 +256,7 @@ fn computer_use_picker_includes_vision_models_without_duplicate_entries() {
text_only.display_name = "Text-only model".to_owned();
preferences.models_by_feature.agent_mode.choices = vec![visual.clone(), text_only];
preferences.models_by_feature.computer_use = Some(AvailableLLMs {
legacy_reasoning_aliases: HashMap::new(),
default_id: "computer-use-agent-auto".into(),
choices: vec![auto, visual],
preferred_codex_model_id: None,
@@ -1276,3 +1277,105 @@ fn model_selector_labels_and_reasoning_groups_include_configured_provider() {
other.id = LLMId::from("model-b::connection::second-connection");
assert_ne!(first.reasoning_group_key(), other.reasoning_group_key());
}
#[test]
fn reasoning_discovery_uses_explicit_lists_and_supported_flags_only() {
assert_eq!(
advertised_reasoning_efforts(&serde_json::json!({
"reasoning_effort_levels": [" low ", "high", "low", "", null],
"supports_max_reasoning_effort": true
})),
["low", "high"]
);
assert_eq!(
advertised_reasoning_efforts(&serde_json::json!({
"supported_reasoning_levels": [{"effort": "minimal"}, {"effort": "high"}]
})),
["minimal", "high"]
);
assert_eq!(
advertised_reasoning_efforts(&serde_json::json!({
"supports_reasoning": true,
"supports_low_reasoning_effort": true,
"supports_high_reasoning_effort": false,
"supports_xhigh_reasoning_effort": true
})),
["low", "xhigh"]
);
assert!(advertised_reasoning_efforts(&serde_json::json!({
"id": "gpt-test-high", "supports_reasoning": true
})).is_empty());
assert!(advertised_reasoning_efforts(&serde_json::json!({
"reasoning_effort_levels": [], "supports_low_reasoning_effort": true
})).is_empty());
}
#[test]
fn overlapping_connections_keep_all_reasoning_variants_in_one_family() {
App::test((), |mut app| async move {
initialize_settings_for_tests(&mut app);
AISettings::handle(&app).update(&mut app, |settings, ctx| {
settings.openai_enabled.set_value(true, ctx).unwrap();
let mut reasoning_model = openai_model("shared-model");
reasoning_model.reasoning_efforts = vec!["low".to_owned(), "high".to_owned()];
settings.openai_providers.set_value(vec![
OpenAIProviderConfig {
kind: OpenAIProviderKind::LiteLLM,
name: "First connection".to_owned(),
base_url: "https://first.test/v1".to_owned(),
models: vec![openai_model("shared-model")],
enabled: true,
api_key: None,
project_id: None,
location: None,
},
OpenAIProviderConfig {
kind: OpenAIProviderKind::LiteLLM,
name: "Second connection".to_owned(),
base_url: "https://second.test/v1".to_owned(),
models: vec![reasoning_model],
enabled: true,
api_key: None,
project_id: None,
location: None,
},
], ctx).unwrap();
});
let mut preferences = empty_preferences();
app.read(|ctx| preferences.inject_openai_models(ctx));
let choices: Vec<_> = preferences.models_by_feature.agent_mode.choices.iter()
.filter(|model| model.description.as_deref() == Some("Second connection"))
.collect();
assert_eq!(choices.len(), 3);
let base = choices.iter().find(|model| model.reasoning_level.is_none()).unwrap();
assert!(base.id.as_str().contains("::connection::"));
let group_key = base.reasoning_group_key();
for model in choices {
assert_eq!(model.reasoning_group_key(), group_key);
let route = preferences.openai_client_config_for_model(model.id.as_str()).unwrap();
assert_eq!(route.model.as_deref(), Some("shared-model"));
assert_eq!(route.reasoning_effort, model.reasoning_level);
}
let old_id = LLMId::from("shared-model::reasoning::low");
let canonical = preferences.get_llm_info(&old_id).unwrap();
assert_eq!(canonical.description.as_deref(), Some("Second connection"));
assert!(canonical.id.as_str().contains("::connection::"));
assert_eq!(preferences.openai_client_config_for_model(old_id.as_str()).unwrap().reasoning_effort.as_deref(), Some("low"));
AISettings::handle(&app).update(&mut app, |settings, ctx| {
let mut providers = settings.openai_providers.value().clone();
providers[1].enabled = false;
let mut third = providers[1].clone();
third.enabled = true;
third.name = "Third connection".to_owned();
third.base_url = "https://third.test/v1".to_owned();
providers.push(third);
settings.openai_providers.set_value(providers, ctx).unwrap();
});
app.read(|ctx| preferences.inject_openai_models(ctx));
// A disabled second connection still reserves its legacy IDs: they
// must not silently migrate to the third connection.
assert!(preferences.get_llm_info(&old_id).is_none());
assert!(preferences.openai_client_config_for_model(old_id.as_str()).is_none());
});
}
+6 -1
View File
@@ -21,6 +21,7 @@ pub struct OpenAIClientConfig {
}
pub struct OpenAIClient {
reasoning_effort: Option<String>,
http: reqwest::Client,
base_url: String,
api_key: Option<String>,
@@ -54,6 +55,7 @@ impl OpenAIClient {
pub fn from_config(config: OpenAIClientConfig) -> Self {
let http = reqwest::Client::new();
Self {
reasoning_effort: config.reasoning_effort,
http,
base_url: config.base_url,
api_key: config.api_key,
@@ -62,9 +64,12 @@ impl OpenAIClient {
pub async fn chat_completions_stream(
&self,
request_body: serde_json::Value,
mut request_body: serde_json::Value,
) -> Result<impl Stream<Item = Result<Bytes, reqwest::Error>>, OpenAIError> {
let url = format!("{}/chat/completions", self.base_url.trim_end_matches('/'));
if let Some(effort) = &self.reasoning_effort {
request_body["reasoning_effort"] = effort.clone().into();
}
let mut headers = HeaderMap::new();
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
+21 -13
View File
@@ -307,7 +307,8 @@ pub(crate) async fn provider_runtime_for_request(
model,
max_output_tokens: config.max_output_tokens.map(u64::from),
supports_system_messages: config.supports_system_messages,
}),
})
.with_reasoning_effort(config.reasoning_effort),
),
OpenAIProviderKind::ChatGPTSubscription => Arc::new(ChatGPTSubscriptionRuntime::new(
ChatGPTSubscriptionRuntimeConfig {
@@ -324,7 +325,8 @@ pub(crate) async fn provider_runtime_for_request(
api_key: credential.clone(),
model,
max_output_tokens: config.max_output_tokens.map(u64::from),
});
})
.with_reasoning_effort(config.reasoning_effort);
if stored {
runtime = runtime.with_credential_resolver(Arc::new(move || {
Box::pin(crate::ai::claude_oauth::load_credential(credential.clone()))
@@ -332,17 +334,23 @@ pub(crate) async fn provider_runtime_for_request(
}
Arc::new(runtime)
}
OpenAIProviderKind::Gemini => Arc::new(GeminiRuntime::new(GeminiRuntimeConfig {
api_key: config.api_key.unwrap_or_default(),
model,
max_output_tokens: config.max_output_tokens.map(u64::from),
})),
OpenAIProviderKind::VertexAI => Arc::new(VertexAiRuntime::new(VertexAiRuntimeConfig {
project_id: config.project_id.unwrap_or_default(),
location: config.location.unwrap_or_else(|| "global".to_string()),
model,
max_output_tokens: config.max_output_tokens.map(u64::from),
})),
OpenAIProviderKind::Gemini => Arc::new(
GeminiRuntime::new(GeminiRuntimeConfig {
api_key: config.api_key.unwrap_or_default(),
model,
max_output_tokens: config.max_output_tokens.map(u64::from),
})
.with_reasoning_effort(config.reasoning_effort),
),
OpenAIProviderKind::VertexAI => Arc::new(
VertexAiRuntime::new(VertexAiRuntimeConfig {
project_id: config.project_id.unwrap_or_default(),
location: config.location.unwrap_or_else(|| "global".to_string()),
model,
max_output_tokens: config.max_output_tokens.map(u64::from),
})
.with_reasoning_effort(config.reasoning_effort),
),
},
crate::ai::provider::ProviderConfig::Bedrock(config) => {
let max_output_tokens = Some(bedrock_max_output_tokens(&model));
+1 -1
View File
@@ -904,7 +904,7 @@ pub struct OpenAIModelConfig {
pub capability_overrides: HashMap<String, ModelCapabilityOverride>,
#[serde(default)]
#[schemars(
description = "Reasoning effort modes supported by this model when using the ChatGPT subscription provider."
description = "Discrete reasoning effort modes explicitly supported by this model and provider."
)]
pub reasoning_efforts: Vec<String>,
#[serde(default = "default_enabled")]
+12 -3
View File
@@ -24,7 +24,7 @@ use super::model_spec_scores::{
MODEL_SPECS_DESCRIPTION, MODEL_SPECS_TITLE, REASONING_LEVEL_DESCRIPTION, REASONING_LEVEL_TITLE,
};
use crate::ai::custom_model_routers::is_custom_router_id;
use crate::ai::execution_profiles::model_menu_items::is_auto;
use crate::ai::execution_profiles::model_menu_items::{collapse_model_reasoning_choices, is_auto};
use crate::ai::llms::{
is_using_api_key_for_provider, should_show_bedrock_icon_for_model, DisableReason, LLMId,
LLMInfo, LLMPreferences, LLMProvider, LLMSpec,
@@ -47,6 +47,14 @@ use crate::workspaces::user_workspaces::UserWorkspaces;
const AUTO_BEDROCK_TOOLTIP: &str = "Warp uses Bedrock when the model Auto selects supports it; otherwise it may use Warp-hosted inference.";
fn model_picker_label(llm: &LLMInfo) -> String {
if llm.has_reasoning_level() && !is_custom_router_id(llm.id.as_str()) {
llm.label_with_provider(llm.base_model_name())
} else {
llm.menu_display_name()
}
}
#[derive(Clone, Debug)]
pub struct AcceptModel {
pub id: LLMId,
@@ -228,6 +236,7 @@ impl SyncDataSource for ModelSelectorDataSource {
})
.collect_vec()
};
let choices = collapse_model_reasoning_choices(choices, Some(&active_llm_id));
let choices = Self::order_model_choices(llm_preferences, choices);
let query_text = query.text.trim().to_lowercase();
@@ -243,7 +252,7 @@ impl SyncDataSource for ModelSelectorDataSource {
.into_iter()
.filter_map(|llm| {
let match_result = match_indices_case_insensitive(
llm.menu_display_name().to_lowercase().as_str(),
model_picker_label(llm).to_lowercase().as_str(),
query_text.as_str(),
)?;
@@ -327,7 +336,7 @@ impl ModelSearchItem {
spec: llm.spec.clone(),
leading_icon,
credential_icon,
display_text: llm.menu_display_name(),
display_text: model_picker_label(llm),
is_selected: &llm.id == active_llm_id,
is_custom_endpoint,
is_custom_router,
+30 -17
View File
@@ -1012,12 +1012,13 @@ impl ProfileModelSelector {
// Group reasoning variants by model and configured provider.
// Use "auto" as the key for all auto models so they collapse together.
// Only group models that have reasoning levels - others stay separate.
// Include the provider-default entry in its reasoning group, while
// leaving models without advertised reasoning choices unchanged.
let mut groups: IndexMap<String, Vec<&LLMInfo>> = IndexMap::new();
for llm in &server_choices {
let key = if is_auto(llm) {
"auto".to_string()
} else if llm.has_reasoning_level() {
} else if has_reasoning_variants(llm, &server_choices) {
llm.reasoning_group_key()
} else {
llm.id.to_string()
@@ -1155,7 +1156,7 @@ impl ProfileModelSelector {
llm.display_name.clone()
};
Self::make_sidecar_item(label, &llm.id, is_selected)
Self::make_sidecar_item(label, llm, is_selected)
})
.collect(),
ModelSpecSidecarKind::Reasoning => {
@@ -1186,11 +1187,13 @@ impl ProfileModelSelector {
let items: Vec<MenuItem<ProfileModelSelectorAction>> = self
.all_model_choices
.iter()
.filter(|llm| llm.reasoning_group_key() == base_name && llm.has_reasoning_level())
.filter(|llm| llm.reasoning_group_key() == base_name)
.map(|llm| {
let is_selected = llm.id == active_llm_id;
let label = llm.reasoning_level().unwrap_or_default();
Self::make_sidecar_item(label, &llm.id, is_selected)
let label = llm
.reasoning_level()
.unwrap_or_else(|| "Provider default".to_owned());
Self::make_sidecar_item(label, llm, is_selected)
})
.collect();
@@ -1208,12 +1211,18 @@ impl ProfileModelSelector {
fn make_sidecar_item(
label: String,
llm_id: &LLMId,
llm: &LLMInfo,
is_selected: bool,
) -> MenuItem<ProfileModelSelectorAction> {
let mut fields = MenuItemFields::new(label)
.with_font_size_override(14.)
.with_on_select_action(ProfileModelSelectorAction::SelectModel(llm_id.clone()));
.with_on_select_action(ProfileModelSelectorAction::SelectModel(llm.id.clone()));
if let Some(reason) = &llm.disable_reason {
fields = fields
.with_disabled(true)
.with_tooltip(reason.tooltip_text());
}
if is_selected {
fields = fields.with_icon(Icon::Check);
@@ -1275,12 +1284,11 @@ impl ProfileModelSelector {
item.item_on_select_action(),
Some(ProfileModelSelectorAction::SelectAutoModel)
)
} else if active_llm.has_reasoning_level() {
} else if let Some(ProfileModelSelectorAction::SelectReasoningModel(name)) =
item.item_on_select_action()
{
// Match the selected model and its configured provider.
matches!(
item.item_on_select_action(),
Some(ProfileModelSelectorAction::SelectReasoningModel(name)) if *name == active_llm.reasoning_group_key()
)
*name == active_llm.reasoning_group_key()
} else {
let item_model_id = item
.item_on_select_action()
@@ -1334,12 +1342,17 @@ impl ProfileModelSelector {
.cloned()
}
ProfileModelSelectorAction::SelectReasoningModel(base_name) => {
// Get the first reasoning variant for this base model
// Preserve the current effort when hovering its model.
// For another model, prefer the provider-default entry.
let preferences = LLMPreferences::as_ref(ctx);
let active = preferences.get_active_base_model(ctx, Some(self.terminal_view_id));
if active.reasoning_group_key() == *base_name {
return Some(active.clone());
}
self.all_model_choices
.iter()
.find(|llm| {
llm.reasoning_group_key() == *base_name && llm.has_reasoning_level()
})
.filter(|llm| llm.reasoning_group_key() == *base_name)
.min_by_key(|llm| llm.has_reasoning_level())
.cloned()
}
_ => None,
+25 -1
View File
@@ -2742,6 +2742,7 @@ pub struct TerminalView {
pending_user_query_kind: Option<PendingUserQueryKind>,
queued_prompt_callback: Option<ConversationFinishedCallback>,
last_observed_conversation_status: HashMap<AIConversationId, ConversationStatus>,
last_desktop_notification_status: HashMap<AIConversationId, ConversationStatus>,
last_observed_active_subagent: HashMap<AIConversationId, bool>,
usage_footer_view_ids: HashMap<EntityId, EntityId>,
@@ -4415,6 +4416,7 @@ impl TerminalView {
pending_user_query_kind: None,
queued_prompt_callback: None,
last_observed_conversation_status: Default::default(),
last_desktop_notification_status: Default::default(),
last_observed_active_subagent: Default::default(),
usage_footer_view_ids: Default::default(),
block_onboarding_active: false,
@@ -6466,6 +6468,9 @@ impl TerminalView {
ConversationStatusUpdate::Restored => None,
});
if !new_status.should_trigger_notification() {
self.last_desktop_notification_status.remove(conversation_id);
}
// Don't send notifications or insert ambient agent session ended tombstone
// if we're restoring this conversation on startup.
if matches!(update, ConversationStatusUpdate::Restored) {
@@ -6579,6 +6584,7 @@ impl TerminalView {
// The singleton's own history subscriber drops queue state for cleared
// conversations, so no `clear_all` call is needed here.
self.last_observed_conversation_status.clear();
self.last_desktop_notification_status.clear();
self.last_observed_active_subagent.clear();
if let Some(active_conversation_id) = active_conversation_id {
self.ai_controller.update(ctx, |controller, ctx| {
@@ -6645,6 +6651,7 @@ impl TerminalView {
// needed here.
self.last_observed_conversation_status
.remove(conversation_id);
self.last_desktop_notification_status.remove(conversation_id);
self.last_observed_active_subagent.remove(conversation_id);
}
BlocklistAIHistoryEvent::CreatedSubtask {
@@ -13719,6 +13726,11 @@ impl TerminalView {
// Desktop notifications — only when navigated away and not in-progress.
if !self.is_navigated_away_from_window(ctx)
|| matches!(status, CLIAgentSessionStatus::InProgress)
// Linked workers use conversation notifications, which suppress
// routine completions and retain actionable permission requests.
|| self
.child_conversation_id_for_cli_status_updates(ctx)
.is_some()
{
return;
}
@@ -16128,7 +16140,17 @@ impl TerminalView {
return;
};
if conversation.is_entirely_passive()
|| !conversation.status().should_trigger_notification()
|| !conversation
.status()
.should_notify_for_agent(conversation.is_child_agent_conversation())
|| self.last_desktop_notification_status.get(conversation_id)
== Some(conversation.status())
{
return;
}
if matches!(conversation.status(), ConversationStatus::Success)
&& QueuedQueryModel::as_ref(ctx).has_autofireable_prompt(*conversation_id)
{
return;
}
@@ -16142,6 +16164,8 @@ impl TerminalView {
} else {
NotificationsTrigger::AgentTaskCompleted(block_summary.success)
};
self.last_desktop_notification_status
.insert(*conversation_id, conversation.status().clone());
self.send_agent_desktop_notification_or_show_banner(
trigger,
block_summary.title,