Separate model reasoning selections and reduce agent notification noise
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -268,7 +268,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": [
|
||||||
{
|
{
|
||||||
@@ -286,6 +286,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('/'));
|
||||||
|
|
||||||
|
|||||||
@@ -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, ¤t_permissions, ctx);
|
Self::update_profile_name_editor(&self.profile_name_editor, ¤t_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()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
all_models
|
|
||||||
.iter()
|
/// Keep one visible model entry per connection while preserving the selected
|
||||||
.filter(|other| other.reasoning_group_key() == llm.reasoning_group_key() && other.id != llm.id)
|
/// reasoning variant as the entry's action. Unknown reasoning metadata stays unchanged.
|
||||||
.any(|other| other.has_reasoning_level())
|
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);
|
||||||
|
}
|
||||||
|
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>(
|
fn with_cost_and_profile_info<A: Action + Clone>(
|
||||||
|
|||||||
+89
-31
@@ -386,6 +386,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>,
|
||||||
@@ -423,12 +425,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
|
||||||
@@ -516,6 +522,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(),
|
||||||
@@ -546,6 +553,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(),
|
||||||
@@ -1131,6 +1139,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);
|
||||||
@@ -1241,6 +1254,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,
|
||||||
@@ -1263,32 +1278,34 @@ 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),
|
||||||
);
|
);
|
||||||
if !seen_model_ids.insert(model_key.clone()) {
|
let legacy_key = model_key.clone();
|
||||||
use sha2::{Digest, Sha256};
|
let legacy_unclaimed = legacy_reserved_model_ids.insert(legacy_key.clone());
|
||||||
let connection_hash =
|
if let Some(suffix) = &connection_suffix {
|
||||||
format!("{:x}", Sha256::digest(connection_identity.as_bytes()));
|
model_key = format!("{model_key}::connection::{suffix}");
|
||||||
model_key = format!("{model_key}::connection::{connection_hash}");
|
}
|
||||||
if !seen_model_ids.insert(model_key.clone()) {
|
if !seen_model_ids.insert(model_key.clone()) {
|
||||||
continue;
|
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
|
||||||
// connection's saved model selection.
|
// connection's saved model selection.
|
||||||
@@ -1296,6 +1313,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 {
|
||||||
@@ -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");
|
log::info!("[openai/litellm] Injected {total_injected} model(s) into available choices");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1998,7 +2030,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()
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(not(target_family = "wasm"))]
|
#[cfg(not(target_family = "wasm"))]
|
||||||
fn normalize_codex_release_version(version: &str) -> Option<String> {
|
fn normalize_codex_release_version(version: &str) -> Option<String> {
|
||||||
let version = version.trim();
|
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"))
|
.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(),
|
||||||
@@ -3283,7 +3335,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,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -3322,6 +3374,12 @@ async fn fetch_litellm_catalog(
|
|||||||
.iter()
|
.iter()
|
||||||
.find(|info| info.model_id == model.model_id)
|
.find(|info| info.model_id == model.model_id)
|
||||||
.cloned()
|
.cloned()
|
||||||
|
.map(|mut info| {
|
||||||
|
if info.reasoning_efforts.is_empty() {
|
||||||
|
info.reasoning_efforts = model.reasoning_efforts.clone();
|
||||||
|
}
|
||||||
|
info
|
||||||
|
})
|
||||||
.unwrap_or(model)
|
.unwrap_or(model)
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
@@ -3442,7 +3500,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,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -256,6 +256,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,
|
||||||
@@ -1276,3 +1277,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());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
|||||||
@@ -307,7 +307,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 {
|
||||||
@@ -324,7 +325,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()))
|
||||||
@@ -332,17 +334,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(
|
||||||
|
GeminiRuntime::new(GeminiRuntimeConfig {
|
||||||
api_key: config.api_key.unwrap_or_default(),
|
api_key: config.api_key.unwrap_or_default(),
|
||||||
model,
|
model,
|
||||||
max_output_tokens: config.max_output_tokens.map(u64::from),
|
max_output_tokens: config.max_output_tokens.map(u64::from),
|
||||||
})),
|
})
|
||||||
OpenAIProviderKind::VertexAI => Arc::new(VertexAiRuntime::new(VertexAiRuntimeConfig {
|
.with_reasoning_effort(config.reasoning_effort),
|
||||||
|
),
|
||||||
|
OpenAIProviderKind::VertexAI => Arc::new(
|
||||||
|
VertexAiRuntime::new(VertexAiRuntimeConfig {
|
||||||
project_id: config.project_id.unwrap_or_default(),
|
project_id: config.project_id.unwrap_or_default(),
|
||||||
location: config.location.unwrap_or_else(|| "global".to_string()),
|
location: config.location.unwrap_or_else(|| "global".to_string()),
|
||||||
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),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
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));
|
||||||
|
|||||||
@@ -904,7 +904,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")]
|
||||||
|
|||||||
@@ -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 = "Warp uses Bedrock when the model Auto selects supports it; otherwise it may use Warp-hosted inference.";
|
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)]
|
#[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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -2742,6 +2742,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>,
|
||||||
|
|
||||||
@@ -4415,6 +4416,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,
|
||||||
@@ -6466,6 +6468,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) {
|
||||||
@@ -6579,6 +6584,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| {
|
||||||
@@ -6645,6 +6651,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 {
|
||||||
@@ -13719,6 +13726,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;
|
||||||
}
|
}
|
||||||
@@ -16128,7 +16140,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;
|
||||||
}
|
}
|
||||||
@@ -16142,6 +16164,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,
|
||||||
|
|||||||
@@ -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| {
|
}
|
||||||
!cursor.is_empty() && cursor.bytes().all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
|
let cursor = page["last_id"]
|
||||||
}).ok_or_else(|| "Invalid Anthropic model pagination cursor.".to_string())?;
|
.as_str()
|
||||||
|
.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}"),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user