Compare commits

...
2 Commits
Author SHA1 Message Date
rkw6086 6655fe7822 Merge remote-tracking branch 'ryserve/master'
# Conflicts:
#	app/src/ai/llms.rs
2026-10-25 01:32:18 -05:00
rkw6086 596b7266a4 Separate model reasoning selections and reduce agent notification noise 2026-10-25 01:31:39 -05:00
19 changed files with 826 additions and 138 deletions
@@ -249,7 +249,7 @@ impl AgentNotificationsModel {
terminal_surface_id, terminal_surface_id,
conversation_id, conversation_id,
// We shouldn't trigger toasts when restoring conversations on startup. // We shouldn't trigger toasts when restoring conversations on startup.
update: ConversationStatusUpdate::Changed { .. }, update: ConversationStatusUpdate::Changed { prev_status },
.. ..
} = event } = event
else { else {
@@ -266,6 +266,21 @@ impl AgentNotificationsModel {
} }
let status = updated_conversation.status().clone(); 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(); let latest_query = updated_conversation.latest_user_query();
if FeatureFlag::HOANotifications.is_enabled() { if FeatureFlag::HOANotifications.is_enabled() {
self.handle_history_event_for_mailbox( self.handle_history_event_for_mailbox(
@@ -283,6 +298,12 @@ impl AgentNotificationsModel {
return; 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) { if is_terminal_view_visible(*terminal_surface_id, ctx) {
return; return;
} }
@@ -354,18 +375,10 @@ impl AgentNotificationsModel {
); );
} }
ConversationStatus::Cancelled => { ConversationStatus::Cancelled => {
let artifacts = self.flush_pending_artifacts(conversation_id); // Steering and replacing a turn cancels the old generation;
self.add_notification( // that is not a separate task-completion notification.
title, self.pending_artifacts.remove(&conversation_id);
"Task was cancelled.".to_owned(), self.remove_notification_by_source(origin, ctx);
NotificationCategory::Complete,
oz_agent,
origin,
terminal_view_id,
artifacts,
metadata.branch,
ctx,
);
} }
ConversationStatus::Blocked { blocked_action } => { ConversationStatus::Blocked { blocked_action } => {
self.add_notification( self.add_notification(
@@ -484,6 +497,10 @@ pub enum AgentManagementEvent {
} }
impl ConversationStatus { 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 /// Returns true if the updating the conversation with this status should trigger some
/// notification to the user. /// notification to the user.
/// ///
@@ -297,6 +297,36 @@ fn should_trigger_notification_returns_false_for_cancelled() {
assert!(!ConversationStatus::Cancelled.should_trigger_notification()); 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 // Mailbox suppression for non-terminal status updates. In App::test the
// `is_conversation_open` gate always returns false, so the // `is_conversation_open` gate always returns false, so the
// WaitingForEvents and InProgress arms both clear stale notifications // WaitingForEvents and InProgress arms both clear stale notifications
+5 -1
View File
@@ -253,7 +253,7 @@ impl CrosscheckReviewer {
let effective_model = config.model.unwrap_or(model_id); 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, "model": effective_model,
"messages": [ "messages": [
{ {
@@ -271,6 +271,10 @@ impl CrosscheckReviewer {
"stream": false "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 // 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('/')); 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::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::{ use crate::ai::execution_profiles::profiles::{
AIExecutionProfilesModel, AIExecutionProfilesModelEvent, ClientProfileId, AIExecutionProfilesModel, AIExecutionProfilesModelEvent, ClientProfileId,
}; };
@@ -34,6 +36,7 @@ use crate::cloud_object::model::generic_string_model::StringModel;
use crate::editor::{ use crate::editor::{
EditorView, Event as EditorEvent, InteractionState, SingleLineEditorOptions, TextOptions, EditorView, Event as EditorEvent, InteractionState, SingleLineEditorOptions, TextOptions,
}; };
use crate::menu::{MenuItem, MenuItemFields, MenuTooltipPosition};
use crate::pane_group::focus_state::PaneFocusHandle; use crate::pane_group::focus_state::PaneFocusHandle;
use crate::pane_group::pane::view; use crate::pane_group::pane::view;
use crate::pane_group::{BackingView, PaneConfiguration, PaneEvent}; use crate::pane_group::{BackingView, PaneConfiguration, PaneEvent};
@@ -132,6 +135,13 @@ pub enum ExecutionProfileEditorViewEvent {
Pane(PaneEvent), Pane(PaneEvent),
} }
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ProfileModelRole {
Base,
FullTerminalUse,
ComputerUse,
}
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub enum ExecutionProfileEditorViewAction { pub enum ExecutionProfileEditorViewAction {
Save, Save,
@@ -139,6 +149,10 @@ pub enum ExecutionProfileEditorViewAction {
SetBaseModel { SetBaseModel {
id: LLMId, id: LLMId,
}, },
SetModelReasoning {
role: ProfileModelRole,
id: LLMId,
},
/// Fired continuously while the user drags the context window slider. /// Fired continuously while the user drags the context window slider.
ContextWindowSliderDragged { ContextWindowSliderDragged {
value: u32, value: u32,
@@ -198,6 +212,10 @@ pub struct ExecutionProfileEditorView {
full_terminal_use_model_dropdown: full_terminal_use_model_dropdown:
ViewHandle<FilterableDropdown<ExecutionProfileEditorViewAction>>, ViewHandle<FilterableDropdown<ExecutionProfileEditorViewAction>>,
computer_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>>, permission_mode_dropdown: ViewHandle<Dropdown<ExecutionProfileEditorViewAction>>,
command_denylist_editor: ViewHandle<SubmittableTextInput>, command_denylist_editor: ViewHandle<SubmittableTextInput>,
command_allowlist_editor: ViewHandle<SubmittableTextInput>, command_allowlist_editor: ViewHandle<SubmittableTextInput>,
@@ -301,6 +319,9 @@ impl ExecutionProfileEditorView {
dropdown.set_menu_width(MODEL_MENU_WIDTH, ctx); dropdown.set_menu_width(MODEL_MENU_WIDTH, ctx);
dropdown 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 command_allowlist_editor = ctx.add_typed_action_view(|ctx| {
let mut input = SubmittableTextInput::new(ctx).validate_on_edit(|s| Regex::new(s).is_ok()); let mut input = SubmittableTextInput::new(ctx).validate_on_edit(|s| Regex::new(s).is_ok());
input.set_placeholder_text("e.g. ^cargo test$", ctx); input.set_placeholder_text("e.g. ^cargo test$", ctx);
@@ -361,6 +382,10 @@ impl ExecutionProfileEditorView {
coding_model_dropdown, coding_model_dropdown,
full_terminal_use_model_dropdown, full_terminal_use_model_dropdown,
computer_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, permission_mode_dropdown,
command_denylist_editor, command_denylist_editor,
command_allowlist_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. // Refresh model dropdowns when BYO API keys update so key icons reflect current state.
@@ -506,6 +532,7 @@ impl ExecutionProfileEditorView {
ctx, ctx,
); );
me.sync_context_window_editor(ctx, false); me.sync_context_window_editor(ctx, false);
me.refresh_reasoning_dropdowns(ctx);
ctx.notify(); ctx.notify();
}, },
); );
@@ -647,6 +674,62 @@ impl ExecutionProfileEditorView {
Self::update_profile_name_editor(&self.profile_name_editor, &current_permissions, ctx); Self::update_profile_name_editor(&self.profile_name_editor, &current_permissions, ctx);
self.sync_context_window_editor(ctx, false); 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>( fn refresh_filterable_model_dropdown<G, A, D>(
@@ -673,7 +756,10 @@ impl ExecutionProfileEditorView {
let llm_prefs = LLMPreferences::handle(ctx); let llm_prefs = LLMPreferences::handle(ctx);
let llm_prefs = llm_prefs.as_ref(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 let has_upgrade_gated_models = choices
.iter() .iter()
@@ -685,7 +771,7 @@ impl ExecutionProfileEditorView {
None, None,
None, None,
false, false,
false, true,
ctx, ctx,
); );
dropdown.set_rich_items(items, ctx); dropdown.set_rich_items(items, ctx);
@@ -700,9 +786,6 @@ impl ExecutionProfileEditorView {
dropdown.clear_footer(ctx); 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); dropdown.set_selected_by_action(create_action(model_to_select), ctx);
ctx.notify(); ctx.notify();
}); });
@@ -1036,6 +1119,16 @@ impl TypedActionView for ExecutionProfileEditorView {
self.sync_context_window_editor(ctx, true); self.sync_context_window_editor(ctx, true);
ctx.notify(); 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 } => { ExecutionProfileEditorViewAction::ContextWindowSliderDragged { value } => {
if !AISettings::as_ref(ctx).is_any_ai_enabled(ctx) { if !AISettings::as_ref(ctx).is_any_ai_enabled(ctx) {
self.sync_context_window_editor(ctx, true); self.sync_context_window_editor(ctx, true);
@@ -5,6 +5,9 @@ use warpui::App;
use super::ui_helpers::context_window_snap_values; use super::ui_helpers::context_window_snap_values;
use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel; 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::{ use crate::ai::execution_profiles::{
has_configurable_context_window, should_show_long_context_pricing_warning, AIExecutionProfile, has_configurable_context_window, should_show_long_context_pricing_warning, AIExecutionProfile,
AIExecutionProfileAppExt as _, AIExecutionProfileAppExt as _,
@@ -316,3 +319,53 @@ fn non_openai_configurable_context_ignores_gpt_flag_and_does_not_show_openai_war
false 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::ui_components::text::Span;
use warpui::{AppContext, Element, SingletonEntity, ViewHandle}; use warpui::{AppContext, Element, SingletonEntity, ViewHandle};
use super::{ExecutionProfileEditorView, ExecutionProfileEditorViewAction}; use super::{ExecutionProfileEditorView, ExecutionProfileEditorViewAction, ProfileModelRole};
use crate::ai::blocklist::BlocklistAIPermissions; use crate::ai::blocklist::BlocklistAIPermissions;
use crate::ai::execution_profiles::{ use crate::ai::execution_profiles::{
long_context_pricing_warning_title, AIExecutionProfile, AIExecutionProfileAppExt as _, long_context_pricing_warning_title, AIExecutionProfile, AIExecutionProfileAppExt as _,
@@ -247,6 +247,29 @@ fn render_permission_row<T: DropdownItemAction>(
.finish() .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( pub fn render_models_section(
appearance: &Appearance, appearance: &Appearance,
view: &ExecutionProfileEditorView, view: &ExecutionProfileEditorView,
@@ -262,6 +285,10 @@ pub fn render_models_section(
&view.base_model_dropdown, &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) { if let Some(row) = render_context_window_row(appearance, view, app) {
column.add_child(row); column.add_child(row);
} }
@@ -273,6 +300,10 @@ pub fn render_models_section(
&view.full_terminal_use_model_dropdown, &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() { if FeatureFlag::LocalComputerUse.is_enabled() {
column.add_child(render_filterable_dropdown_row( column.add_child(render_filterable_dropdown_row(
appearance, 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.", "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, &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()) Container::new(column.finish())
@@ -1,3 +1,4 @@
use std::collections::HashSet;
use std::sync::Arc; use std::sync::Arc;
use galaxy_core::ui::Icon; use galaxy_core::ui::Icon;
@@ -21,15 +22,55 @@ pub fn is_auto(llm: &LLMInfo) -> bool {
|| llm.id.to_string().to_lowercase().contains("auto") || 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 { pub fn has_reasoning_variants(llm: &LLMInfo, all_models: &[&LLMInfo]) -> bool {
if !llm.has_reasoning_level() { all_models.iter().any(|other| {
return false; 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 result
.iter() }
.filter(|other| other.reasoning_group_key() == llm.reasoning_group_key() && other.id != llm.id)
.any(|other| other.has_reasoning_level()) /// 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>( fn with_cost_and_profile_info<A: Action + Clone>(
+87 -32
View File
@@ -388,6 +388,8 @@ pub struct AvailableLLMs {
/// The Warp "default" LLM. /// The Warp "default" LLM.
default_id: LLMId, default_id: LLMId,
choices: Vec<LLMInfo>, choices: Vec<LLMInfo>,
#[serde(skip)]
legacy_reasoning_aliases: HashMap<LLMId, LLMId>,
#[serde(default)] #[serde(default)]
preferred_codex_model_id: Option<LLMId>, preferred_codex_model_id: Option<LLMId>,
@@ -425,12 +427,16 @@ impl AvailableLLMs {
Ok(Self { Ok(Self {
default_id, default_id,
choices: choices.into_iter().collect(), choices: choices.into_iter().collect(),
legacy_reasoning_aliases: HashMap::new(),
preferred_codex_model_id, preferred_codex_model_id,
}) })
} }
fn info_for_id(&self, id: &LLMId) -> Option<&LLMInfo> { 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 /// Returns the info for the given id only if the model is usable (present
@@ -518,6 +524,7 @@ impl ModelsByFeature {
/// Used both in `ModelsByFeature::default()` and as a fallback in `get_computer_use_available()`. /// Used both in `ModelsByFeature::default()` and as a fallback in `get_computer_use_available()`.
fn default_computer_use_llms() -> AvailableLLMs { fn default_computer_use_llms() -> AvailableLLMs {
AvailableLLMs { AvailableLLMs {
legacy_reasoning_aliases: HashMap::new(),
default_id: "computer-use-agent-auto".to_owned().into(), default_id: "computer-use-agent-auto".to_owned().into(),
choices: vec![LLMInfo { choices: vec![LLMInfo {
display_name: "auto".to_owned(), display_name: "auto".to_owned(),
@@ -548,6 +555,7 @@ impl Default for ModelsByFeature {
/// `inject_bedrock_models` once real models are loaded. /// `inject_bedrock_models` once real models are loaded.
fn default() -> Self { fn default() -> Self {
let placeholder = || AvailableLLMs { let placeholder = || AvailableLLMs {
legacy_reasoning_aliases: HashMap::new(),
default_id: "placeholder".to_owned().into(), default_id: "placeholder".to_owned().into(),
choices: vec![LLMInfo { choices: vec![LLMInfo {
display_name: "No models configured".to_owned(), display_name: "No models configured".to_owned(),
@@ -1128,6 +1136,11 @@ impl LLMPreferences {
cli.choices.retain(|m| m.provider != LLMProvider::LiteLLM); cli.choices.retain(|m| m.provider != LLMProvider::LiteLLM);
} }
self.openai_provider_routing.clear(); 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); let settings = AISettings::as_ref(ctx);
self.inject_acp_models(ctx); self.inject_acp_models(ctx);
@@ -1238,6 +1251,8 @@ impl LLMPreferences {
let mut total_injected = 0; let mut total_injected = 0;
let mut seen_model_ids: HashSet<String> = HashSet::new(); 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(); let mut seen_connections = HashSet::new();
for ( for (
provider_name, provider_name,
@@ -1260,31 +1275,33 @@ impl LLMPreferences {
continue; continue;
} }
let reasoning_efforts: Vec<Option<&String>> = // All providers may advertise discrete effort choices. Keep the
if provider_kind == OpenAIProviderKind::ChatGPTSubscription { // default model key stable and route explicit choices separately.
// Keep the base model as the provider-default mode, then expose each let reasoning_efforts = std::iter::once(None)
// explicitly supported effort as a separate selectable variant. .chain(model.reasoning_efforts.iter().map(Some));
std::iter::once(None)
.chain(model.reasoning_efforts.iter().map(Some))
.collect()
} else {
vec![None]
};
// 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 { for reasoning_effort in reasoning_efforts {
let reasoning_effort = reasoning_effort.cloned(); let reasoning_effort = reasoning_effort.cloned();
let mut model_key = reasoning_effort.as_deref().map_or_else( let mut model_key = reasoning_effort.as_deref().map_or_else(
|| model.model_id.clone(), || model.model_id.clone(),
|effort| openai_model_variant_id(&model.model_id, effort), |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()) { if !seen_model_ids.insert(model_key.clone()) {
use sha2::{Digest, Sha256}; continue;
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;
}
} }
// Disabled entries still reserve their selection keys so // Disabled entries still reserve their selection keys so
// toggling a connection cannot silently reroute another // toggling a connection cannot silently reroute another
@@ -1293,6 +1310,9 @@ impl LLMPreferences {
continue; 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 // Register the routing entry. Reasoning variants keep the provider's
// actual model ID while using their synthetic key only for selection. // actual model ID while using their synthetic key only for selection.
let client_config = OpenAIClientConfig { let client_config = OpenAIClientConfig {
@@ -1351,6 +1371,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"); log::info!("[openai/litellm] Injected {total_injected} model(s) into available choices");
} }
@@ -1995,7 +2027,7 @@ impl LLMPreferences {
provider: None, provider: None,
supports_system_messages: Some(true), supports_system_messages: Some(true),
capability_overrides: std::collections::HashMap::new(), capability_overrides: std::collections::HashMap::new(),
reasoning_efforts: Vec::new(), reasoning_efforts: model.reasoning_efforts,
enabled: true, enabled: true,
}) })
.collect() .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()) .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()
}
/// Keep an endpoint's recommended window separate from its optional expanded /// Keep an endpoint's recommended window separate from its optional expanded
/// maximum. A bare model ID carries no token metadata, so it must not replace /// maximum. A bare model ID carries no token metadata, so it must not replace
/// richer limits when the two catalog endpoints are combined. /// richer limits when the two catalog endpoints are combined.
@@ -3172,17 +3234,7 @@ fn chatgpt_models_from_codex_response(body: &serde_json::Value) -> Vec<OpenAIMod
.any(|modality| modality.as_str() == Some("image")) .any(|modality| modality.as_str() == Some("image"))
}) })
.unwrap_or(true); .unwrap_or(true);
let reasoning_efforts = model["supported_reasoning_levels"] let reasoning_efforts = advertised_reasoning_efforts(model);
.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();
Some(OpenAIModelConfig { Some(OpenAIModelConfig {
model_id: model_id.to_string(), model_id: model_id.to_string(),
@@ -3322,7 +3374,7 @@ async fn fetch_from_litellm_model_info(
model_info["supports_system_messages"].as_bool() model_info["supports_system_messages"].as_bool()
}, },
capability_overrides: std::collections::HashMap::new(), capability_overrides: std::collections::HashMap::new(),
reasoning_efforts: Vec::new(), reasoning_efforts: advertised_reasoning_efforts(model_info),
enabled: true, enabled: true,
}) })
}) })
@@ -3382,6 +3434,9 @@ async fn fetch_litellm_catalog(
(Some(_), None) => model.context_size, (Some(_), None) => model.context_size,
_ => info.context_size, _ => info.context_size,
}; };
if enriched.reasoning_efforts.is_empty() {
enriched.reasoning_efforts = model.reasoning_efforts.clone();
}
enriched enriched
}) })
.unwrap_or(model) .unwrap_or(model)
@@ -3490,7 +3545,7 @@ async fn fetch_from_openai_models(
m["supports_system_messages"].as_bool() m["supports_system_messages"].as_bool()
}, },
capability_overrides: std::collections::HashMap::new(), capability_overrides: std::collections::HashMap::new(),
reasoning_efforts: Vec::new(), reasoning_efforts: advertised_reasoning_efforts(m),
enabled: true, enabled: true,
}) })
}) })
+103
View File
@@ -304,6 +304,7 @@ fn computer_use_picker_includes_vision_models_without_duplicate_entries() {
text_only.display_name = "Text-only model".to_owned(); 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.agent_mode.choices = vec![visual.clone(), text_only];
preferences.models_by_feature.computer_use = Some(AvailableLLMs { preferences.models_by_feature.computer_use = Some(AvailableLLMs {
legacy_reasoning_aliases: HashMap::new(),
default_id: "computer-use-agent-auto".into(), default_id: "computer-use-agent-auto".into(),
choices: vec![auto, visual], choices: vec![auto, visual],
preferred_codex_model_id: None, preferred_codex_model_id: None,
@@ -1474,3 +1475,105 @@ fn model_selector_labels_and_reasoning_groups_include_configured_provider() {
other.id = LLMId::from("model-b::connection::second-connection"); other.id = LLMId::from("model-b::connection::second-connection");
assert_ne!(first.reasoning_group_key(), other.reasoning_group_key()); 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 { pub struct OpenAIClient {
reasoning_effort: Option<String>,
http: reqwest::Client, http: reqwest::Client,
base_url: String, base_url: String,
api_key: Option<String>, api_key: Option<String>,
@@ -54,6 +55,7 @@ impl OpenAIClient {
pub fn from_config(config: OpenAIClientConfig) -> Self { pub fn from_config(config: OpenAIClientConfig) -> Self {
let http = reqwest::Client::new(); let http = reqwest::Client::new();
Self { Self {
reasoning_effort: config.reasoning_effort,
http, http,
base_url: config.base_url, base_url: config.base_url,
api_key: config.api_key, api_key: config.api_key,
@@ -62,9 +64,12 @@ impl OpenAIClient {
pub async fn chat_completions_stream( pub async fn chat_completions_stream(
&self, &self,
request_body: serde_json::Value, mut request_body: serde_json::Value,
) -> Result<impl Stream<Item = Result<Bytes, reqwest::Error>>, OpenAIError> { ) -> Result<impl Stream<Item = Result<Bytes, reqwest::Error>>, OpenAIError> {
let url = format!("{}/chat/completions", self.base_url.trim_end_matches('/')); 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(); let mut headers = HeaderMap::new();
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
+21 -13
View File
@@ -312,7 +312,8 @@ pub(crate) async fn provider_runtime_for_request(
model, model,
max_output_tokens: config.max_output_tokens.map(u64::from), max_output_tokens: config.max_output_tokens.map(u64::from),
supports_system_messages: config.supports_system_messages, supports_system_messages: config.supports_system_messages,
}), })
.with_reasoning_effort(config.reasoning_effort),
), ),
OpenAIProviderKind::ChatGPTSubscription => Arc::new(ChatGPTSubscriptionRuntime::new( OpenAIProviderKind::ChatGPTSubscription => Arc::new(ChatGPTSubscriptionRuntime::new(
ChatGPTSubscriptionRuntimeConfig { ChatGPTSubscriptionRuntimeConfig {
@@ -328,7 +329,8 @@ pub(crate) async fn provider_runtime_for_request(
api_key: credential.clone(), api_key: credential.clone(),
model, model,
max_output_tokens: config.max_output_tokens.map(u64::from), max_output_tokens: config.max_output_tokens.map(u64::from),
}); })
.with_reasoning_effort(config.reasoning_effort);
if stored { if stored {
runtime = runtime.with_credential_resolver(Arc::new(move || { runtime = runtime.with_credential_resolver(Arc::new(move || {
Box::pin(crate::ai::claude_oauth::load_credential(credential.clone())) Box::pin(crate::ai::claude_oauth::load_credential(credential.clone()))
@@ -336,17 +338,23 @@ pub(crate) async fn provider_runtime_for_request(
} }
Arc::new(runtime) Arc::new(runtime)
} }
OpenAIProviderKind::Gemini => Arc::new(GeminiRuntime::new(GeminiRuntimeConfig { OpenAIProviderKind::Gemini => Arc::new(
api_key: config.api_key.unwrap_or_default(), GeminiRuntime::new(GeminiRuntimeConfig {
model, api_key: config.api_key.unwrap_or_default(),
max_output_tokens: config.max_output_tokens.map(u64::from), 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(), .with_reasoning_effort(config.reasoning_effort),
location: config.location.unwrap_or_else(|| "global".to_string()), ),
model, OpenAIProviderKind::VertexAI => Arc::new(
max_output_tokens: config.max_output_tokens.map(u64::from), 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) => { crate::ai::provider::ProviderConfig::Bedrock(config) => {
let max_output_tokens = Some(bedrock_max_output_tokens(&model)); let max_output_tokens = Some(bedrock_max_output_tokens(&model));
+1 -1
View File
@@ -928,7 +928,7 @@ pub struct OpenAIModelConfig {
pub capability_overrides: HashMap<String, ModelCapabilityOverride>, pub capability_overrides: HashMap<String, ModelCapabilityOverride>,
#[serde(default)] #[serde(default)]
#[schemars( #[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>, pub reasoning_efforts: Vec<String>,
#[serde(default = "default_enabled")] #[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, 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::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::{ use crate::ai::llms::{
is_using_api_key_for_provider, should_show_bedrock_icon_for_model, DisableReason, LLMId, is_using_api_key_for_provider, should_show_bedrock_icon_for_model, DisableReason, LLMId,
LLMInfo, LLMPreferences, LLMProvider, LLMSpec, LLMInfo, LLMPreferences, LLMProvider, LLMSpec,
@@ -47,6 +47,14 @@ use crate::workspaces::user_workspaces::UserWorkspaces;
const AUTO_BEDROCK_TOOLTIP: &str = "Galaxy uses Bedrock when the model Auto selects supports it. Other models use their configured provider."; const AUTO_BEDROCK_TOOLTIP: &str = "Galaxy uses Bedrock when the model Auto selects supports it. Other models use their configured provider.";
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)] #[derive(Clone, Debug)]
pub struct AcceptModel { pub struct AcceptModel {
pub id: LLMId, pub id: LLMId,
@@ -228,6 +236,7 @@ impl SyncDataSource for ModelSelectorDataSource {
}) })
.collect_vec() .collect_vec()
}; };
let choices = collapse_model_reasoning_choices(choices, Some(&active_llm_id));
let choices = Self::order_model_choices(llm_preferences, choices); let choices = Self::order_model_choices(llm_preferences, choices);
let query_text = query.text.trim().to_lowercase(); let query_text = query.text.trim().to_lowercase();
@@ -243,7 +252,7 @@ impl SyncDataSource for ModelSelectorDataSource {
.into_iter() .into_iter()
.filter_map(|llm| { .filter_map(|llm| {
let match_result = match_indices_case_insensitive( 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(), query_text.as_str(),
)?; )?;
@@ -327,7 +336,7 @@ impl ModelSearchItem {
spec: llm.spec.clone(), spec: llm.spec.clone(),
leading_icon, leading_icon,
credential_icon, credential_icon,
display_text: llm.menu_display_name(), display_text: model_picker_label(llm),
is_selected: &llm.id == active_llm_id, is_selected: &llm.id == active_llm_id,
is_custom_endpoint, is_custom_endpoint,
is_custom_router, is_custom_router,
+30 -17
View File
@@ -1012,12 +1012,13 @@ impl ProfileModelSelector {
// Group reasoning variants by model and configured provider. // Group reasoning variants by model and configured provider.
// Use "auto" as the key for all auto models so they collapse together. // 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(); let mut groups: IndexMap<String, Vec<&LLMInfo>> = IndexMap::new();
for llm in &server_choices { for llm in &server_choices {
let key = if is_auto(llm) { let key = if is_auto(llm) {
"auto".to_string() "auto".to_string()
} else if llm.has_reasoning_level() { } else if has_reasoning_variants(llm, &server_choices) {
llm.reasoning_group_key() llm.reasoning_group_key()
} else { } else {
llm.id.to_string() llm.id.to_string()
@@ -1155,7 +1156,7 @@ impl ProfileModelSelector {
llm.display_name.clone() llm.display_name.clone()
}; };
Self::make_sidecar_item(label, &llm.id, is_selected) Self::make_sidecar_item(label, llm, is_selected)
}) })
.collect(), .collect(),
ModelSpecSidecarKind::Reasoning => { ModelSpecSidecarKind::Reasoning => {
@@ -1186,11 +1187,13 @@ impl ProfileModelSelector {
let items: Vec<MenuItem<ProfileModelSelectorAction>> = self let items: Vec<MenuItem<ProfileModelSelectorAction>> = self
.all_model_choices .all_model_choices
.iter() .iter()
.filter(|llm| llm.reasoning_group_key() == base_name && llm.has_reasoning_level()) .filter(|llm| llm.reasoning_group_key() == base_name)
.map(|llm| { .map(|llm| {
let is_selected = llm.id == active_llm_id; let is_selected = llm.id == active_llm_id;
let label = llm.reasoning_level().unwrap_or_default(); let label = llm
Self::make_sidecar_item(label, &llm.id, is_selected) .reasoning_level()
.unwrap_or_else(|| "Provider default".to_owned());
Self::make_sidecar_item(label, llm, is_selected)
}) })
.collect(); .collect();
@@ -1208,12 +1211,18 @@ impl ProfileModelSelector {
fn make_sidecar_item( fn make_sidecar_item(
label: String, label: String,
llm_id: &LLMId, llm: &LLMInfo,
is_selected: bool, is_selected: bool,
) -> MenuItem<ProfileModelSelectorAction> { ) -> MenuItem<ProfileModelSelectorAction> {
let mut fields = MenuItemFields::new(label) let mut fields = MenuItemFields::new(label)
.with_font_size_override(14.) .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 { if is_selected {
fields = fields.with_icon(Icon::Check); fields = fields.with_icon(Icon::Check);
@@ -1275,12 +1284,11 @@ impl ProfileModelSelector {
item.item_on_select_action(), item.item_on_select_action(),
Some(ProfileModelSelectorAction::SelectAutoModel) 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. // Match the selected model and its configured provider.
matches!( *name == active_llm.reasoning_group_key()
item.item_on_select_action(),
Some(ProfileModelSelectorAction::SelectReasoningModel(name)) if *name == active_llm.reasoning_group_key()
)
} else { } else {
let item_model_id = item let item_model_id = item
.item_on_select_action() .item_on_select_action()
@@ -1334,12 +1342,17 @@ impl ProfileModelSelector {
.cloned() .cloned()
} }
ProfileModelSelectorAction::SelectReasoningModel(base_name) => { 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 self.all_model_choices
.iter() .iter()
.find(|llm| { .filter(|llm| llm.reasoning_group_key() == *base_name)
llm.reasoning_group_key() == *base_name && llm.has_reasoning_level() .min_by_key(|llm| llm.has_reasoning_level())
})
.cloned() .cloned()
} }
_ => None, _ => None,
+25 -1
View File
@@ -2744,6 +2744,7 @@ pub struct TerminalView {
pending_user_query_kind: Option<PendingUserQueryKind>, pending_user_query_kind: Option<PendingUserQueryKind>,
queued_prompt_callback: Option<ConversationFinishedCallback>, queued_prompt_callback: Option<ConversationFinishedCallback>,
last_observed_conversation_status: HashMap<AIConversationId, ConversationStatus>, last_observed_conversation_status: HashMap<AIConversationId, ConversationStatus>,
last_desktop_notification_status: HashMap<AIConversationId, ConversationStatus>,
last_observed_active_subagent: HashMap<AIConversationId, bool>, last_observed_active_subagent: HashMap<AIConversationId, bool>,
usage_footer_view_ids: HashMap<EntityId, EntityId>, usage_footer_view_ids: HashMap<EntityId, EntityId>,
@@ -4417,6 +4418,7 @@ impl TerminalView {
pending_user_query_kind: None, pending_user_query_kind: None,
queued_prompt_callback: None, queued_prompt_callback: None,
last_observed_conversation_status: Default::default(), last_observed_conversation_status: Default::default(),
last_desktop_notification_status: Default::default(),
last_observed_active_subagent: Default::default(), last_observed_active_subagent: Default::default(),
usage_footer_view_ids: Default::default(), usage_footer_view_ids: Default::default(),
block_onboarding_active: false, block_onboarding_active: false,
@@ -6468,6 +6470,9 @@ impl TerminalView {
ConversationStatusUpdate::Restored => None, 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 // Don't send notifications or insert ambient agent session ended tombstone
// if we're restoring this conversation on startup. // if we're restoring this conversation on startup.
if matches!(update, ConversationStatusUpdate::Restored) { if matches!(update, ConversationStatusUpdate::Restored) {
@@ -6581,6 +6586,7 @@ impl TerminalView {
// The singleton's own history subscriber drops queue state for cleared // The singleton's own history subscriber drops queue state for cleared
// conversations, so no `clear_all` call is needed here. // conversations, so no `clear_all` call is needed here.
self.last_observed_conversation_status.clear(); self.last_observed_conversation_status.clear();
self.last_desktop_notification_status.clear();
self.last_observed_active_subagent.clear(); self.last_observed_active_subagent.clear();
if let Some(active_conversation_id) = active_conversation_id { if let Some(active_conversation_id) = active_conversation_id {
self.ai_controller.update(ctx, |controller, ctx| { self.ai_controller.update(ctx, |controller, ctx| {
@@ -6647,6 +6653,7 @@ impl TerminalView {
// needed here. // needed here.
self.last_observed_conversation_status self.last_observed_conversation_status
.remove(conversation_id); .remove(conversation_id);
self.last_desktop_notification_status.remove(conversation_id);
self.last_observed_active_subagent.remove(conversation_id); self.last_observed_active_subagent.remove(conversation_id);
} }
BlocklistAIHistoryEvent::CreatedSubtask { BlocklistAIHistoryEvent::CreatedSubtask {
@@ -13687,6 +13694,11 @@ impl TerminalView {
// Desktop notifications — only when navigated away and not in-progress. // Desktop notifications — only when navigated away and not in-progress.
if !self.is_navigated_away_from_window(ctx) if !self.is_navigated_away_from_window(ctx)
|| matches!(status, CLIAgentSessionStatus::InProgress) || 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; return;
} }
@@ -16096,7 +16108,17 @@ impl TerminalView {
return; return;
}; };
if conversation.is_entirely_passive() 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; return;
} }
@@ -16110,6 +16132,8 @@ impl TerminalView {
} else { } else {
NotificationsTrigger::AgentTaskCompleted(block_summary.success) NotificationsTrigger::AgentTaskCompleted(block_summary.success)
}; };
self.last_desktop_notification_status
.insert(*conversation_id, conversation.status().clone());
self.send_agent_desktop_notification_or_show_banner( self.send_agent_desktop_notification_or_show_banner(
trigger, trigger,
block_summary.title, block_summary.title,
+121 -24
View File
@@ -2,15 +2,15 @@ use std::collections::HashSet;
use std::sync::{Arc, OnceLock}; use std::sync::{Arc, OnceLock};
use async_trait::async_trait; use async_trait::async_trait;
use futures::StreamExt;
use futures::future::BoxFuture; use futures::future::BoxFuture;
use futures::StreamExt;
use galaxy_agent_core::{ use galaxy_agent_core::{
AgentError, AgentErrorKind, AgentEventStream, AgentRuntime, RuntimeCapabilities, AgentError, AgentErrorKind, AgentEventStream, AgentRuntime, RuntimeCapabilities,
RuntimeDescriptor, RuntimeKind, TurnControl, TurnRequest, RuntimeDescriptor, RuntimeKind, TurnControl, TurnRequest,
}; };
use rig_core::client::{CompletionClient, ModelListingClient}; use rig_core::client::{CompletionClient, ModelListingClient};
use rig_core::providers::gemini;
use rig_core::http_client::{self, HttpClientExt}; use rig_core::http_client::{self, HttpClientExt};
use rig_core::providers::gemini;
use crate::anthropic_auth::{anthropic_client, prepare_subscription_request, restore_tool_names}; use crate::anthropic_auth::{anthropic_client, prepare_subscription_request, restore_tool_names};
use crate::request::build_completion_request; use crate::request::build_completion_request;
@@ -22,6 +22,7 @@ pub struct RigModelInfo {
pub display_name: String, pub display_name: String,
pub context_size: Option<u32>, pub context_size: Option<u32>,
pub max_output_tokens: Option<u32>, pub max_output_tokens: Option<u32>,
pub reasoning_efforts: Vec<String>,
} }
pub async fn discover_anthropic_models(api_key: &str) -> Result<Vec<RigModelInfo>, String> { pub async fn discover_anthropic_models(api_key: &str) -> Result<Vec<RigModelInfo>, String> {
@@ -30,30 +31,48 @@ pub async fn discover_anthropic_models(api_key: &str) -> Result<Vec<RigModelInfo
let mut cursors = HashSet::new(); let mut cursors = HashSet::new();
let mut path = "/v1/models?limit=1000".to_string(); let mut path = "/v1/models?limit=1000".to_string();
loop { loop {
let request = client.get(&path).and_then(|request| Ok(request.body(http_client::NoBody)?)) let request = client
.get(&path)
.and_then(|request| Ok(request.body(http_client::NoBody)?))
.map_err(|_| "Could not build the Anthropic models request.".to_string())?; .map_err(|_| "Could not build the Anthropic models request.".to_string())?;
let response = client.send::<_, Vec<u8>>(request).await let response = client
.send::<_, Vec<u8>>(request)
.await
.map_err(|_| "Could not retrieve Anthropic models.".to_string())?; .map_err(|_| "Could not retrieve Anthropic models.".to_string())?;
if !response.status().is_success() { if !response.status().is_success() {
return Err(format!("Anthropic model discovery returned HTTP {}.", response.status())); return Err(format!(
"Anthropic model discovery returned HTTP {}.",
response.status()
));
} }
let body = response.into_body().await let body = response
.into_body()
.await
.map_err(|_| "Could not read the Anthropic models response.".to_string())?; .map_err(|_| "Could not read the Anthropic models response.".to_string())?;
let page: serde_json::Value = serde_json::from_slice(&body) let page: serde_json::Value = serde_json::from_slice(&body)
.map_err(|_| "Invalid Anthropic models response.".to_string())?; .map_err(|_| "Invalid Anthropic models response.".to_string())?;
let entries = page["data"].as_array() let entries = page["data"]
.as_array()
.ok_or_else(|| "Anthropic models response is missing its model list.".to_string())?; .ok_or_else(|| "Anthropic models response is missing its model list.".to_string())?;
for entry in entries { for entry in entries {
models.push(anthropic_model_info(entry)?); models.push(anthropic_model_info(entry)?);
} }
match page["has_more"].as_bool() { match page["has_more"].as_bool() {
Some(false) => return Ok(models), Some(false) => return Ok(models),
Some(true) => {}, Some(true) => {}
None => return Err("Anthropic models response is missing pagination state.".to_string()), None => {
return Err("Anthropic models response is missing pagination state.".to_string())
}
} }
let cursor = page["last_id"].as_str().filter(|cursor| { let cursor = page["last_id"]
!cursor.is_empty() && cursor.bytes().all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) .as_str()
}).ok_or_else(|| "Invalid Anthropic model pagination cursor.".to_string())?; .filter(|cursor| {
!cursor.is_empty()
&& cursor.bytes().all(|byte| {
byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')
})
})
.ok_or_else(|| "Invalid Anthropic model pagination cursor.".to_string())?;
if !cursors.insert(cursor.to_string()) { if !cursors.insert(cursor.to_string()) {
return Err("Anthropic model pagination did not advance.".to_string()); return Err("Anthropic model pagination did not advance.".to_string());
} }
@@ -62,17 +81,24 @@ pub async fn discover_anthropic_models(api_key: &str) -> Result<Vec<RigModelInfo
} }
fn positive_token_limit(value: &serde_json::Value) -> Option<u32> { fn positive_token_limit(value: &serde_json::Value) -> Option<u32> {
value.as_u64().and_then(|tokens| u32::try_from(tokens).ok()).filter(|tokens| *tokens > 0) value
.as_u64()
.and_then(|tokens| u32::try_from(tokens).ok())
.filter(|tokens| *tokens > 0)
} }
fn anthropic_model_info(entry: &serde_json::Value) -> Result<RigModelInfo, String> { fn anthropic_model_info(entry: &serde_json::Value) -> Result<RigModelInfo, String> {
let id = entry["id"].as_str().filter(|id| !id.is_empty()) let id = entry["id"]
.as_str()
.filter(|id| !id.is_empty())
.ok_or_else(|| "Anthropic returned a model without an ID.".to_string())?; .ok_or_else(|| "Anthropic returned a model without an ID.".to_string())?;
// Read live limits first. Rig's generic model lister drops these fields. // Read live limits first. Rig's generic model lister drops these fields.
// The models.dev snapshot is metadata only, never an availability allowlist. // The models.dev snapshot is metadata only, never an availability allowlist.
static LIMITS: OnceLock<serde_json::Value> = OnceLock::new(); static LIMITS: OnceLock<serde_json::Value> = OnceLock::new();
let limits = LIMITS.get_or_init(|| serde_json::from_str(include_str!("../data/anthropic_limits.json")) let limits = LIMITS.get_or_init(|| {
.expect("bundled Anthropic limits must be valid JSON")); serde_json::from_str(include_str!("../data/anthropic_limits.json"))
.expect("bundled Anthropic limits must be valid JSON")
});
let fallback = &limits["models"][id]; let fallback = &limits["models"][id];
Ok(RigModelInfo { Ok(RigModelInfo {
id: id.to_string(), id: id.to_string(),
@@ -81,9 +107,22 @@ fn anthropic_model_info(entry: &serde_json::Value) -> Result<RigModelInfo, Strin
.or_else(|| positive_token_limit(&fallback["context"])), .or_else(|| positive_token_limit(&fallback["context"])),
max_output_tokens: positive_token_limit(&entry["max_tokens"]) max_output_tokens: positive_token_limit(&entry["max_tokens"])
.or_else(|| positive_token_limit(&fallback["output"])), .or_else(|| positive_token_limit(&fallback["output"])),
reasoning_efforts: anthropic_reasoning_efforts(entry),
}) })
} }
fn anthropic_reasoning_efforts(entry: &serde_json::Value) -> Vec<String> {
let effort = &entry["capabilities"]["effort"];
if effort["supported"].as_bool() != Some(true) {
return Vec::new();
}
["low", "medium", "high", "xhigh", "max"]
.into_iter()
.filter(|level| effort[*level]["supported"].as_bool() == Some(true))
.map(str::to_string)
.collect()
}
pub async fn discover_gemini_models(api_key: &str) -> Result<Vec<RigModelInfo>, String> { pub async fn discover_gemini_models(api_key: &str) -> Result<Vec<RigModelInfo>, String> {
let client = gemini::Client::new(api_key).map_err(|error| error.to_string())?; let client = gemini::Client::new(api_key).map_err(|error| error.to_string())?;
let models = client let models = client
@@ -98,6 +137,7 @@ pub async fn discover_gemini_models(api_key: &str) -> Result<Vec<RigModelInfo>,
id: model.id, id: model.id,
context_size: model.context_length, context_size: model.context_length,
max_output_tokens: None, max_output_tokens: None,
reasoning_efforts: Vec::new(),
}) })
.collect()) .collect())
} }
@@ -125,6 +165,7 @@ pub fn vertex_ai_model_catalog() -> Vec<RigModelInfo> {
display_name: display_name.to_string(), display_name: display_name.to_string(),
context_size: None, context_size: None,
max_output_tokens: None, max_output_tokens: None,
reasoning_efforts: Vec::new(),
}) })
.collect() .collect()
} }
@@ -168,6 +209,7 @@ pub struct AnthropicRuntime {
config: AnthropicRuntimeConfig, config: AnthropicRuntimeConfig,
descriptor: RuntimeDescriptor, descriptor: RuntimeDescriptor,
credential_resolver: Option<AnthropicCredentialResolver>, credential_resolver: Option<AnthropicCredentialResolver>,
reasoning_effort: Option<String>,
} }
impl std::fmt::Debug for AnthropicRuntime { impl std::fmt::Debug for AnthropicRuntime {
@@ -181,12 +223,18 @@ impl std::fmt::Debug for AnthropicRuntime {
} }
impl AnthropicRuntime { impl AnthropicRuntime {
pub fn with_reasoning_effort(mut self, effort: Option<String>) -> Self {
self.reasoning_effort = effort;
self
}
pub fn new(config: AnthropicRuntimeConfig) -> Self { pub fn new(config: AnthropicRuntimeConfig) -> Self {
let descriptor = native_descriptor("anthropic", &config.model); let descriptor = native_descriptor("anthropic", &config.model);
Self { Self {
config, config,
descriptor, descriptor,
credential_resolver: None, credential_resolver: None,
reasoning_effort: None,
} }
} }
@@ -224,8 +272,15 @@ impl AgentRuntime for AnthropicRuntime {
.map_err(|error| AgentError::new(AgentErrorKind::Configuration, error.to_string()))?; .map_err(|error| AgentError::new(AgentErrorKind::Configuration, error.to_string()))?;
let mut model = client.completion_model(&self.config.model); let mut model = client.completion_model(&self.config.model);
let max_output_tokens = request.max_output_tokens.or(self.config.max_output_tokens); let max_output_tokens = request.max_output_tokens.or(self.config.max_output_tokens);
let mut completion_request = let mut completion_request = build_completion_request(
build_completion_request(request, max_output_tokens, true, true, None)?; request,
max_output_tokens,
true,
true,
self.reasoning_effort
.as_deref()
.map(anthropic_reasoning_params),
)?;
let aliases = if crate::is_claude_subscription_token(&credential) { let aliases = if crate::is_claude_subscription_token(&credential) {
model = model.with_prompt_caching(); model = model.with_prompt_caching();
Some(prepare_subscription_request(&mut completion_request)?) Some(prepare_subscription_request(&mut completion_request)?)
@@ -256,14 +311,24 @@ pub struct GeminiRuntimeConfig {
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct GeminiRuntime { pub struct GeminiRuntime {
reasoning_effort: Option<String>,
config: GeminiRuntimeConfig, config: GeminiRuntimeConfig,
descriptor: RuntimeDescriptor, descriptor: RuntimeDescriptor,
} }
impl GeminiRuntime { impl GeminiRuntime {
pub fn with_reasoning_effort(mut self, effort: Option<String>) -> Self {
self.reasoning_effort = effort;
self
}
pub fn new(config: GeminiRuntimeConfig) -> Self { pub fn new(config: GeminiRuntimeConfig) -> Self {
let descriptor = native_descriptor("gemini", &config.model); let descriptor = native_descriptor("gemini", &config.model);
Self { config, descriptor } Self {
config,
descriptor,
reasoning_effort: None,
}
} }
} }
@@ -288,8 +353,15 @@ impl AgentRuntime for GeminiRuntime {
.map_err(|error| AgentError::new(AgentErrorKind::Configuration, error.to_string()))?; .map_err(|error| AgentError::new(AgentErrorKind::Configuration, error.to_string()))?;
let model = client.completion_model(&self.config.model); let model = client.completion_model(&self.config.model);
let max_output_tokens = request.max_output_tokens.or(self.config.max_output_tokens); let max_output_tokens = request.max_output_tokens.or(self.config.max_output_tokens);
let completion_request = let completion_request = build_completion_request(
build_completion_request(request, max_output_tokens, true, true, None)?; request,
max_output_tokens,
true,
true,
self.reasoning_effort
.as_deref()
.map(gemini_reasoning_params),
)?;
start_model_turn(model, completion_request, control, max_output_tokens).await start_model_turn(model, completion_request, control, max_output_tokens).await
} }
} }
@@ -304,14 +376,24 @@ pub struct VertexAiRuntimeConfig {
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct VertexAiRuntime { pub struct VertexAiRuntime {
reasoning_effort: Option<String>,
config: VertexAiRuntimeConfig, config: VertexAiRuntimeConfig,
descriptor: RuntimeDescriptor, descriptor: RuntimeDescriptor,
} }
impl VertexAiRuntime { impl VertexAiRuntime {
pub fn with_reasoning_effort(mut self, effort: Option<String>) -> Self {
self.reasoning_effort = effort;
self
}
pub fn new(config: VertexAiRuntimeConfig) -> Self { pub fn new(config: VertexAiRuntimeConfig) -> Self {
let descriptor = native_descriptor("vertex-ai", &config.model); let descriptor = native_descriptor("vertex-ai", &config.model);
Self { config, descriptor } Self {
config,
descriptor,
reasoning_effort: None,
}
} }
} }
@@ -343,12 +425,27 @@ impl AgentRuntime for VertexAiRuntime {
.map_err(|error| AgentError::new(AgentErrorKind::Configuration, error.to_string()))?; .map_err(|error| AgentError::new(AgentErrorKind::Configuration, error.to_string()))?;
let model = client.completion_model(&self.config.model); let model = client.completion_model(&self.config.model);
let max_output_tokens = request.max_output_tokens.or(self.config.max_output_tokens); let max_output_tokens = request.max_output_tokens.or(self.config.max_output_tokens);
let completion_request = let completion_request = build_completion_request(
build_completion_request(request, max_output_tokens, true, true, None)?; request,
max_output_tokens,
true,
true,
self.reasoning_effort
.as_deref()
.map(gemini_reasoning_params),
)?;
start_model_completion(model, completion_request, control, max_output_tokens).await start_model_completion(model, completion_request, control, max_output_tokens).await
} }
} }
fn anthropic_reasoning_params(effort: &str) -> serde_json::Value {
serde_json::json!({ "output_config": { "effort": effort } })
}
fn gemini_reasoning_params(effort: &str) -> serde_json::Value {
serde_json::json!({ "generationConfig": { "thinkingConfig": { "thinkingLevel": effort } } })
}
fn native_descriptor(provider: &str, model: &str) -> RuntimeDescriptor { fn native_descriptor(provider: &str, model: &str) -> RuntimeDescriptor {
RuntimeDescriptor { RuntimeDescriptor {
id: format!("rig-{provider}:{model}"), id: format!("rig-{provider}:{model}"),
+59 -2
View File
@@ -21,10 +21,67 @@ fn anthropic_discovery_prefers_live_limits_over_catalog_defaults() {
fn anthropic_discovery_uses_exact_catalog_limits_when_api_omits_them() { fn anthropic_discovery_uses_exact_catalog_limits_when_api_omits_them() {
let model = super::anthropic_model_info(&serde_json::json!({ let model = super::anthropic_model_info(&serde_json::json!({
"id": "claude-opus-4-6", "max_input_tokens": null, "max_tokens": null "id": "claude-opus-4-6", "max_input_tokens": null, "max_tokens": null
})).unwrap(); }))
.unwrap();
assert_eq!(model.context_size, Some(1000000)); assert_eq!(model.context_size, Some(1000000));
assert_eq!(model.max_output_tokens, Some(128000)); assert_eq!(model.max_output_tokens, Some(128000));
let unknown = super::anthropic_model_info(&serde_json::json!({"id": "claude-unknown"})).unwrap(); let unknown =
super::anthropic_model_info(&serde_json::json!({"id": "claude-unknown"})).unwrap();
assert_eq!(unknown.context_size, None); assert_eq!(unknown.context_size, None);
assert_eq!(unknown.max_output_tokens, None); assert_eq!(unknown.max_output_tokens, None);
} }
#[test]
fn anthropic_discovery_reads_only_supported_effort_capabilities() {
let model = super::anthropic_model_info(&serde_json::json!({
"id": "claude-future", "capabilities": {"effort": {
"supported": true, "low": {"supported": true},
"medium": {"supported": false}, "high": {"supported": true},
"xhigh": {"supported": true}, "max": {"supported": false}
}}
}))
.unwrap();
assert_eq!(model.reasoning_efforts, ["low", "high", "xhigh"]);
assert!(super::anthropic_reasoning_efforts(&serde_json::json!({
"capabilities": {"effort": {"supported": false, "low": {"supported": true}}}
}))
.is_empty());
assert!(super::anthropic_reasoning_efforts(&serde_json::json!({
"id": "claude-opus-4-6"
}))
.is_empty());
}
#[test]
fn native_reasoning_selections_use_provider_request_fields() {
assert_eq!(
super::anthropic_reasoning_params("high"),
serde_json::json!({
"output_config": {"effort": "high"}
})
);
assert_eq!(
super::gemini_reasoning_params("low"),
serde_json::json!({
"generationConfig": {"thinkingConfig": {"thinkingLevel": "low"}}
})
);
}
#[test]
fn gemini_reasoning_parameters_survive_rig_typed_conversion() {
use rig_core::providers::gemini::completion::gemini_api_types::{
AdditionalParameters, ThinkingLevel,
};
let params: AdditionalParameters =
serde_json::from_value(super::gemini_reasoning_params("medium")).unwrap();
assert_eq!(
params
.generation_config
.unwrap()
.thinking_config
.unwrap()
.thinking_level,
Some(ThinkingLevel::Medium)
);
}
@@ -23,9 +23,15 @@ pub struct OpenAICompatibleRuntimeConfig {
pub struct OpenAICompatibleRuntime { pub struct OpenAICompatibleRuntime {
config: OpenAICompatibleRuntimeConfig, config: OpenAICompatibleRuntimeConfig,
descriptor: RuntimeDescriptor, descriptor: RuntimeDescriptor,
reasoning_effort: Option<String>,
} }
impl OpenAICompatibleRuntime { impl OpenAICompatibleRuntime {
pub fn with_reasoning_effort(mut self, effort: Option<String>) -> Self {
self.reasoning_effort = effort;
self
}
pub fn new(config: OpenAICompatibleRuntimeConfig) -> Self { pub fn new(config: OpenAICompatibleRuntimeConfig) -> Self {
let descriptor = RuntimeDescriptor { let descriptor = RuntimeDescriptor {
id: format!("rig-openai-compatible:{}", config.model), id: format!("rig-openai-compatible:{}", config.model),
@@ -33,7 +39,11 @@ impl OpenAICompatibleRuntime {
kind: RuntimeKind::Provider, kind: RuntimeKind::Provider,
capabilities: RuntimeCapabilities::provider(), capabilities: RuntimeCapabilities::provider(),
}; };
Self { config, descriptor } Self {
config,
descriptor,
reasoning_effort: None,
}
} }
} }
@@ -62,6 +72,7 @@ impl AgentRuntime for OpenAICompatibleRuntime {
control, control,
self.config.max_output_tokens, self.config.max_output_tokens,
self.config.supports_system_messages, self.config.supports_system_messages,
self.reasoning_effort.as_deref(),
) )
.await .await
} }
@@ -73,6 +84,7 @@ async fn start_model_turn<M>(
control: TurnControl, control: TurnControl,
configured_max_output_tokens: Option<u64>, configured_max_output_tokens: Option<u64>,
supports_system_messages: bool, supports_system_messages: bool,
reasoning_effort: Option<&str>,
) -> Result<AgentEventStream, AgentError> ) -> Result<AgentEventStream, AgentError>
where where
M: CompletionModel + Send + Sync + 'static, M: CompletionModel + Send + Sync + 'static,
@@ -82,6 +94,7 @@ where
request, request,
configured_max_output_tokens, configured_max_output_tokens,
supports_system_messages, supports_system_messages,
reasoning_effort,
)?; )?;
start_provider_model_turn(model, completion_request, control, max_output_tokens).await start_provider_model_turn(model, completion_request, control, max_output_tokens).await
} }
@@ -90,15 +103,18 @@ fn build_completion_request(
request: TurnRequest, request: TurnRequest,
configured_max_output_tokens: Option<u64>, configured_max_output_tokens: Option<u64>,
supports_system_messages: bool, supports_system_messages: bool,
reasoning_effort: Option<&str>,
) -> Result<CompletionRequest, AgentError> { ) -> Result<CompletionRequest, AgentError> {
let mut params = serde_json::json!({ "stream_options": { "include_usage": true } });
if let Some(effort) = reasoning_effort {
params["reasoning_effort"] = effort.into();
}
build_provider_completion_request( build_provider_completion_request(
request, request,
configured_max_output_tokens, configured_max_output_tokens,
supports_system_messages, supports_system_messages,
true, true,
Some(serde_json::json!({ Some(params),
"stream_options": { "include_usage": true }
})),
) )
} }
@@ -49,7 +49,7 @@ async fn rig_stream_maps_reasoning_text_usage_and_stop() {
let model = client.completion_model("test-model"); let model = client.completion_model("test-model");
let (_, control) = galaxy_agent_core::turn_control(); let (_, control) = galaxy_agent_core::turn_control();
let events = start_model_turn(model, text_request(), control, None, true) let events = start_model_turn(model, text_request(), control, None, true, None)
.await .await
.unwrap() .unwrap()
.collect::<Vec<_>>() .collect::<Vec<_>>()
@@ -104,7 +104,7 @@ async fn rig_stream_ignores_empty_reasoning_heartbeats() {
let model = client.completion_model("test-model"); let model = client.completion_model("test-model");
let (_, control) = galaxy_agent_core::turn_control(); let (_, control) = galaxy_agent_core::turn_control();
let events = start_model_turn(model, text_request(), control, None, true) let events = start_model_turn(model, text_request(), control, None, true, None)
.await .await
.unwrap() .unwrap()
.collect::<Vec<_>>() .collect::<Vec<_>>()
@@ -118,11 +118,9 @@ async fn rig_stream_ignores_empty_reasoning_heartbeats() {
AgentEvent::ReasoningCompleted { text, signature } AgentEvent::ReasoningCompleted { text, signature }
if text.is_empty() && signature.is_none() if text.is_empty() && signature.is_none()
))); )));
assert!( assert!(events
events .iter()
.iter() .any(|event| matches!(event, AgentEvent::TextDelta { text } if text == "done")));
.any(|event| matches!(event, AgentEvent::TextDelta { text } if text == "done"))
);
} }
#[tokio::test] #[tokio::test]
@@ -176,7 +174,7 @@ async fn usage_at_the_requested_limit_maps_to_max_tokens() {
let mut request = text_request(); let mut request = text_request();
request.max_output_tokens = Some(6); request.max_output_tokens = Some(6);
let events = start_model_turn(model, request, control, None, true) let events = start_model_turn(model, request, control, None, true, None)
.await .await
.unwrap() .unwrap()
.collect::<Vec<_>>() .collect::<Vec<_>>()
@@ -219,7 +217,7 @@ async fn rig_stream_maps_complete_tool_call_without_executing_it() {
input_schema: serde_json::json!({"type": "object"}), input_schema: serde_json::json!({"type": "object"}),
}); });
let events = start_model_turn(model, request, control, None, true) let events = start_model_turn(model, request, control, None, true, None)
.await .await
.unwrap() .unwrap()
.collect::<Vec<_>>() .collect::<Vec<_>>()
@@ -274,7 +272,7 @@ fn request_conversion_preserves_history_tools_and_limits() {
}), }),
}); });
let converted = build_completion_request(request, Some(999), true).unwrap(); let converted = build_completion_request(request, Some(999), true, None).unwrap();
assert_eq!(converted.max_tokens, Some(123)); assert_eq!(converted.max_tokens, Some(123));
assert_eq!(converted.tools.len(), 1); assert_eq!(converted.tools.len(), 1);
@@ -318,7 +316,7 @@ fn request_conversion_preserves_tool_call_and_denied_result_for_the_next_turn()
], ],
); );
let converted = build_completion_request(request, None, true).unwrap(); let converted = build_completion_request(request, None, true, None).unwrap();
let messages = converted.chat_history.iter().collect::<Vec<_>>(); let messages = converted.chat_history.iter().collect::<Vec<_>>();
let Message::Assistant { content, .. } = messages[0] else { let Message::Assistant { content, .. } = messages[0] else {
panic!("expected assistant tool call"); panic!("expected assistant tool call");
@@ -352,7 +350,7 @@ fn request_conversion_places_system_prompt_in_user_message_when_system_role_is_u
let mut request = text_request(); let mut request = text_request();
request.system_prompt = Some("Be useful".to_string()); request.system_prompt = Some("Be useful".to_string());
let converted = build_completion_request(request, None, false).unwrap(); let converted = build_completion_request(request, None, false, None).unwrap();
let messages = converted.chat_history.iter().collect::<Vec<_>>(); let messages = converted.chat_history.iter().collect::<Vec<_>>();
assert_eq!(messages.len(), 2); assert_eq!(messages.len(), 2);
@@ -371,3 +369,34 @@ fn request_conversion_places_system_prompt_in_user_message_when_system_role_is_u
0 0
); );
} }
#[test]
fn selected_reasoning_effort_is_sent_without_changing_the_model() {
let request = build_completion_request(text_request(), None, true, Some("high")).unwrap();
assert_eq!(
request.additional_params,
Some(serde_json::json!({
"stream_options": {"include_usage": true}, "reasoning_effort": "high"
}))
);
let default = build_completion_request(text_request(), None, true, None).unwrap();
assert!(default
.additional_params
.unwrap()
.get("reasoning_effort")
.is_none());
}
#[test]
fn reasoning_effort_survives_openai_request_serialization() {
let request = build_completion_request(text_request(), None, true, Some("low")).unwrap();
let wire = rig_core::providers::openai::completion::CompletionRequest::try_from((
"test-model".to_string(),
request,
))
.unwrap();
let json = serde_json::to_value(wire).unwrap();
assert_eq!(json["model"], "test-model");
assert_eq!(json["reasoning_effort"], "low");
assert_eq!(json["stream_options"]["include_usage"], true);
}