Separate model reasoning selections and reduce agent notification noise
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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('/'));
|
||||
|
||||
|
||||
@@ -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, ¤t_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
@@ -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,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
@@ -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));
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user