Simplify provider settings pages

This commit is contained in:
2026-08-11 02:00:31 -05:00
parent 10412c4c6f
commit 84945cd9be
10 changed files with 1382 additions and 786 deletions
+33 -12
View File
@@ -1040,7 +1040,7 @@ impl LLMPreferences {
};
provider_entries.push((
name,
OpenAIProviderKind::OpenAICompatible,
OpenAIProviderKind::LiteLLM,
true,
base_url,
api_key,
@@ -1057,7 +1057,14 @@ impl LLMPreferences {
.iter()
.filter_map(|provider| {
let missing_credentials = match provider.kind {
OpenAIProviderKind::OpenAICompatible => provider.base_url.trim().is_empty(),
OpenAIProviderKind::OpenAI => {
provider.base_url.trim().is_empty()
|| provider
.api_key
.as_deref()
.is_none_or(|key| key.trim().is_empty())
}
OpenAIProviderKind::LiteLLM => provider.base_url.trim().is_empty(),
OpenAIProviderKind::Anthropic | OpenAIProviderKind::Gemini => provider
.api_key
.as_deref()
@@ -1143,7 +1150,10 @@ impl LLMPreferences {
max_input_tokens: Some(openai_model_context_size(model)),
max_output_tokens: model.max_output_tokens,
use_rig: model.use_rig
|| !matches!(provider_kind, OpenAIProviderKind::OpenAICompatible),
|| !matches!(
provider_kind,
OpenAIProviderKind::OpenAI | OpenAIProviderKind::LiteLLM
),
supports_system_messages: model.supports_system_messages(),
};
self.openai_provider_routing
@@ -1531,6 +1541,7 @@ impl LLMPreferences {
return;
}
let provider_kind = provider.kind;
let requested_base_url = provider.base_url;
let api_key = provider.api_key.filter(|key| !key.is_empty());
let request_base_url = requested_base_url.clone();
@@ -1543,11 +1554,13 @@ impl LLMPreferences {
.build()
.unwrap_or_default();
if provider_kind == OpenAIProviderKind::LiteLLM {
if let Some(models) =
fetch_from_litellm_model_info(base, api_key.as_deref(), &client).await
{
return models;
}
}
fetch_from_openai_models(base, api_key.as_deref(), &client).await
},
@@ -1580,7 +1593,7 @@ impl LLMPreferences {
/// Discovers models for a provider draft without persisting or injecting it.
///
/// The provider setup modal uses this to keep configuration changes atomic
/// The provider setup view uses this to keep configuration changes atomic
/// until the user clicks Save.
#[cfg(not(target_family = "wasm"))]
pub(crate) async fn discover_openai_provider_models(
@@ -1624,7 +1637,9 @@ impl LLMPreferences {
)?;
Some(vertex_ai_model_catalog())
}
OpenAIProviderKind::OpenAICompatible | OpenAIProviderKind::ChatGPTSubscription => None,
OpenAIProviderKind::OpenAI
| OpenAIProviderKind::LiteLLM
| OpenAIProviderKind::ChatGPTSubscription => None,
};
if let Some(models) = native_models {
@@ -1645,19 +1660,25 @@ impl LLMPreferences {
.map_err(|error| format!("Could not create the provider client: {error}"))?;
let api_key = provider.api_key.as_deref().filter(|key| !key.is_empty());
let models = if let Some(models) =
fetch_from_litellm_model_info(&base_url, api_key, &client).await
{
let models = if provider.kind == OpenAIProviderKind::LiteLLM {
if let Some(models) = fetch_from_litellm_model_info(&base_url, api_key, &client).await {
models
} else {
fetch_from_openai_models(&base_url, api_key, &client).await
}
} else {
fetch_from_openai_models(&base_url, api_key, &client).await
};
if models.is_empty() {
return Err(
"The provider responded, but no models were found at /model/info or /models."
.to_string(),
);
let endpoint_description = if provider.kind == OpenAIProviderKind::LiteLLM {
"/model/info or /models"
} else {
"/models"
};
return Err(format!(
"The provider responded, but no models were found at {endpoint_description}."
));
}
Ok(models)
+1 -1
View File
@@ -41,7 +41,7 @@ pub(crate) fn rig_openai_response_stream(
let prepared = prepare_rig_turn(&config, params, supported_tools, supported_cli_agent_tools);
let model_id = prepared.request.model.as_str().to_string();
match config.kind {
OpenAIProviderKind::OpenAICompatible => {
OpenAIProviderKind::OpenAI | OpenAIProviderKind::LiteLLM => {
let runtime = OpenAICompatibleRuntime::new(OpenAICompatibleRuntimeConfig {
base_url: config.base_url,
api_key: config.api_key,
+1 -1
View File
@@ -20,7 +20,7 @@ use crate::ai::skills::SkillDescriptor;
fn config() -> OpenAIClientConfig {
OpenAIClientConfig {
kind: crate::settings::OpenAIProviderKind::OpenAICompatible,
kind: crate::settings::OpenAIProviderKind::LiteLLM,
base_url: "http://localhost:4000/v1".to_string(),
api_key: None,
project_id: None,
+11 -10
View File
@@ -978,10 +978,13 @@ impl ModelCapabilityOverride {
)]
#[serde(rename_all = "snake_case")]
pub enum OpenAIProviderKind {
/// A regular OpenAI-compatible `/chat/completions` endpoint.
#[serde(alias = "openai")]
/// OpenAI's native Chat Completions API.
#[serde(rename = "openai", alias = "open_ai")]
OpenAI,
/// A LiteLLM endpoint using the OpenAI-compatible API plus LiteLLM metadata APIs.
#[serde(rename = "litellm", alias = "openai_compatible")]
#[default]
OpenAICompatible,
LiteLLM,
/// The ChatGPT subscription backend, authenticated with ChatGPT OAuth.
ChatGPTSubscription,
/// Anthropic's native Messages API.
@@ -993,14 +996,12 @@ pub enum OpenAIProviderKind {
VertexAI,
}
/// Configuration for a single OpenAI-compatible provider endpoint.
/// Configuration for a single direct model provider endpoint.
///
/// Multiple providers can be configured simultaneously (e.g. LiteLLM for cloud models,
/// Ollama for local models, etc.). Each provider has its own endpoint, credentials, and model list.
/// Multiple providers can be configured simultaneously. Each provider has its own endpoint,
/// credentials, and model list.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, schemars::JsonSchema)]
#[schemars(
description = "Configuration for an OpenAI-compatible provider endpoint (e.g. LiteLLM, Ollama, vLLM)."
)]
#[schemars(description = "Configuration for a direct model provider endpoint.")]
pub struct OpenAIProviderConfig {
#[serde(default)]
#[schemars(description = "Provider protocol and authentication kind.")]
@@ -1114,7 +1115,7 @@ pub(crate) fn default_chatgpt_provider() -> OpenAIProviderConfig {
fn default_openai_providers() -> Vec<OpenAIProviderConfig> {
vec![
OpenAIProviderConfig {
kind: OpenAIProviderKind::OpenAICompatible,
kind: OpenAIProviderKind::LiteLLM,
enabled: true,
name: "LiteLLM (ai.ryserve.net)".to_string(),
base_url: INITIAL_LITELLM_BASE_URL.to_string(),
+22 -9
View File
@@ -351,7 +351,7 @@ fn initial_litellm_provider_maps_codex_model_to_rig_without_a_committed_key() {
assert_eq!(providers.len(), 2);
let provider = &providers[0];
assert_eq!(provider.kind, OpenAIProviderKind::OpenAICompatible);
assert_eq!(provider.kind, OpenAIProviderKind::LiteLLM);
assert_eq!(provider.base_url, INITIAL_LITELLM_BASE_URL);
assert_eq!(provider.api_key, None);
assert_eq!(provider.models.len(), 1);
@@ -414,13 +414,6 @@ fn initial_litellm_provider_maps_codex_model_to_rig_without_a_committed_key() {
.map(str::to_string)
.collect::<Vec<_>>()
);
let instant = chatgpt
.models
.iter()
.find(|model| model.model_id == "gpt-5.3-instant")
.expect("GPT-5.3 Instant should be in the ChatGPT catalog");
assert!(instant.reasoning_efforts.is_empty());
}
#[test]
@@ -446,9 +439,29 @@ fn native_provider_settings_roundtrip_with_vertex_configuration() {
"models": []
}))
.expect("Legacy provider settings should remain compatible");
assert_eq!(legacy.kind, OpenAIProviderKind::OpenAICompatible);
assert_eq!(legacy.kind, OpenAIProviderKind::LiteLLM);
assert_eq!(legacy.project_id, None);
assert_eq!(legacy.location, None);
let legacy_openai_compatible: OpenAIProviderConfig =
serde_json::from_value(serde_json::json!({
"kind": "openai_compatible",
"name": "Legacy LiteLLM provider",
"base_url": "http://localhost:4000/v1",
"models": []
}))
.expect("Legacy OpenAI-compatible provider settings should deserialize");
assert_eq!(legacy_openai_compatible.kind, OpenAIProviderKind::LiteLLM);
let native_openai: OpenAIProviderConfig = serde_json::from_value(serde_json::json!({
"kind": "openai",
"name": "OpenAI",
"base_url": "https://api.openai.com/v1",
"api_key": "sk-test",
"models": []
}))
.expect("Native OpenAI provider settings should deserialize");
assert_eq!(native_openai.kind, OpenAIProviderKind::OpenAI);
}
#[test]
File diff suppressed because it is too large Load Diff
+74 -26
View File
@@ -86,9 +86,8 @@ mod platform;
mod platform_page;
mod privacy;
mod privacy_page;
mod provider_setup_modal;
mod provider_setup_view;
mod scripting_page;
mod set_default_model_modal;
mod settings_file_footer;
pub(crate) mod settings_page;
mod tab_menu;
@@ -248,6 +247,12 @@ pub enum SettingsSection {
ThirdPartyCLIAgents,
Models,
Experiments,
// ── Providers umbrella subpages ──
ProviderOpenAI,
ProviderLiteLLM,
ProviderChatGPTSubscription,
ProviderBedrock,
ProviderACP,
/// Internal backing-page identifier for CodeSettingsPageView. Multiple subpages
/// (CodeIndexing, EditorAndCodeReview) share this single backing page,
/// so this variant is needed as the key in `settings_pages`.
@@ -276,6 +281,11 @@ impl Display for SettingsSection {
SettingsSection::ThirdPartyCLIAgents => write!(f, "Third party CLI agents"),
SettingsSection::Models => write!(f, "Models"),
SettingsSection::Experiments => write!(f, "Experiments"),
SettingsSection::ProviderOpenAI => write!(f, "OpenAI"),
SettingsSection::ProviderLiteLLM => write!(f, "LiteLLM"),
SettingsSection::ProviderChatGPTSubscription => write!(f, "ChatGPT Subscription"),
SettingsSection::ProviderBedrock => write!(f, "Bedrock"),
SettingsSection::ProviderACP => write!(f, "ACP"),
SettingsSection::Warpify => write!(f, "Wormhole"),
SettingsSection::CodeIndexing => write!(f, "Indexing and projects"),
SettingsSection::EditorAndCodeReview => write!(f, "Editor and Code Review"),
@@ -287,7 +297,7 @@ impl Display for SettingsSection {
impl SettingsSection {
/// Returns true if this section is a subpage under any umbrella.
pub fn is_subpage(&self) -> bool {
self.is_ai_subpage() || self.is_code_subpage()
self.is_ai_subpage() || self.is_provider_subpage() || self.is_code_subpage()
}
/// Returns true if this section is a subpage under the "Agents" umbrella.
@@ -304,6 +314,36 @@ impl SettingsSection {
)
}
/// Returns true if this section is a subpage under the "Providers" umbrella.
pub fn is_provider_subpage(&self) -> bool {
matches!(
self,
Self::ProviderOpenAI
| Self::ProviderLiteLLM
| Self::ProviderChatGPTSubscription
| Self::ProviderBedrock
| Self::ProviderACP
)
}
/// Returns true if this section renders through the AI settings backing page.
pub fn is_ai_backed_subpage(&self) -> bool {
matches!(
self,
Self::WarpAgent
| Self::AgentProfiles
| Self::Knowledge
| Self::ThirdPartyCLIAgents
| Self::Models
| Self::Experiments
| Self::ProviderOpenAI
| Self::ProviderLiteLLM
| Self::ProviderChatGPTSubscription
| Self::ProviderBedrock
| Self::ProviderACP
)
}
/// Returns true if this section is a subpage under the "Code" umbrella.
pub fn is_code_subpage(&self) -> bool {
matches!(self, Self::CodeIndexing | Self::EditorAndCodeReview)
@@ -315,8 +355,8 @@ impl SettingsSection {
match self {
// AgentMCPServers renders the standalone MCPServers page directly.
Self::AgentMCPServers => Self::MCPServers,
// All other AI subpages render within the AI page.
s if s.is_ai_subpage() => Self::AI,
// AI and provider subpages render within the AI page.
s if s.is_ai_backed_subpage() => Self::AI,
// Code subpages render within the Code page.
s if s.is_code_subpage() => Self::Code,
other => *other,
@@ -327,7 +367,6 @@ impl SettingsSection {
pub fn ai_subpages() -> &'static [Self] {
&[
Self::WarpAgent,
Self::Models,
Self::AgentProfiles,
Self::AgentMCPServers,
Self::Knowledge,
@@ -336,6 +375,17 @@ impl SettingsSection {
]
}
/// The ordered list of provider subpage sections shown under the Providers umbrella.
pub fn provider_subpages() -> &'static [Self] {
&[
Self::ProviderOpenAI,
Self::ProviderLiteLLM,
Self::ProviderChatGPTSubscription,
Self::ProviderBedrock,
Self::ProviderACP,
]
}
/// The ordered list of Code subpage sections shown under the Code umbrella.
pub fn code_subpages() -> &'static [Self] {
&[Self::CodeIndexing, Self::EditorAndCodeReview]
@@ -364,14 +414,12 @@ impl FromStr for SettingsSection {
"MCP servers" | "AgentMCPServers" => Ok(Self::AgentMCPServers),
"Knowledge" => Ok(Self::Knowledge),
"Third party CLI agents" | "ThirdPartyCLIAgents" => Ok(Self::ThirdPartyCLIAgents),
"Models"
| "AWS Bedrock"
| "Bedrock"
| "OpenAI / LiteLLM"
| "OpenAI"
| "Agent runtimes"
| "Agent Client Protocol"
| "ACP" => Ok(Self::Models),
"Models" | "Agent runtimes" => Ok(Self::ProviderOpenAI),
"AWS Bedrock" | "Bedrock" => Ok(Self::ProviderBedrock),
"OpenAI / LiteLLM" | "LiteLLM" => Ok(Self::ProviderLiteLLM),
"OpenAI" => Ok(Self::ProviderOpenAI),
"ChatGPT Subscription" | "ChatGPTSubscription" => Ok(Self::ProviderChatGPTSubscription),
"Agent Client Protocol" | "ACP" => Ok(Self::ProviderACP),
"Indexing and projects" | "CodeIndexing" => Ok(Self::CodeIndexing),
"Editor and Code Review" | "EditorAndCodeReview" => Ok(Self::EditorAndCodeReview),
"Experiments" => Ok(Self::Experiments),
@@ -1235,6 +1283,10 @@ impl SettingsView {
"Agents",
SettingsSection::ai_subpages().to_vec(),
)),
SettingsNavItem::Umbrella(SettingsUmbrella::new(
"Providers",
SettingsSection::provider_subpages().to_vec(),
)),
SettingsNavItem::Umbrella(SettingsUmbrella::new(
"Code",
vec![
@@ -1367,7 +1419,10 @@ impl SettingsView {
// For each AI subpage, temporarily switch to that subpage's
// widget set and run the filter to get a subpage-specific result.
self.subpage_filter.clear();
for &subpage_section in SettingsSection::ai_subpages() {
for &subpage_section in SettingsSection::ai_subpages()
.iter()
.chain(SettingsSection::provider_subpages().iter())
{
if subpage_section == SettingsSection::AgentMCPServers {
// AgentMCPServers has its own backing page; handled below.
continue;
@@ -1434,7 +1489,7 @@ impl SettingsView {
// Restore the active subpage after filtering.
if is_search_active {
let current = self.current_settings_page;
if current.is_ai_subpage() && current != SettingsSection::AgentMCPServers {
if current.is_ai_backed_subpage() {
if let Some(subpage) = AISubpage::from_section(current) {
self.ai_page_handle.update(ctx, |view, ctx| {
view.set_active_subpage(Some(subpage), ctx);
@@ -1737,7 +1792,7 @@ impl SettingsView {
fn handle_ai_page_event(&mut self, event: &AISettingsPageEvent, ctx: &mut ViewContext<Self>) {
match event {
AISettingsPageEvent::FocusModal => ctx.focus(&self.search_editor),
AISettingsPageEvent::FocusSearch => ctx.focus(&self.search_editor),
AISettingsPageEvent::OpenAIFactCollection => {
ctx.emit(SettingsViewEvent::OpenAIFactCollection)
}
@@ -1758,10 +1813,6 @@ impl SettingsView {
AISettingsPageEvent::SignupAnonymousUser => {
ctx.emit(SettingsViewEvent::SignupAnonymousUser)
}
AISettingsPageEvent::ShowModal | AISettingsPageEvent::HideModal => {
// Modal rendering is handled in get_modal_content_for_page
ctx.notify();
}
}
}
@@ -1853,8 +1904,8 @@ impl SettingsView {
// When navigating to a subpage, update the backing page's active subpage mode
// and auto-expand the umbrella containing it.
if section.is_subpage() {
// AI subpages: update the AI page's subpage mode.
if section.is_ai_subpage() && section != SettingsSection::AgentMCPServers {
// AI-backed subpages: update the AI page's subpage mode.
if section.is_ai_backed_subpage() {
let subpage = AISubpage::from_section(section);
self.ai_page_handle.update(ctx, |view, ctx| {
view.set_active_subpage(subpage, ctx);
@@ -2113,9 +2164,6 @@ impl SettingsView {
SettingsPageViewHandle::MCPServers(view) => {
view.read(app, |view, _| view.get_modal_content(app))
}
SettingsPageViewHandle::AI(view) => {
view.read(app, |view, _| view.get_modal_content(app))
}
_ => None,
}
}
+76 -16
View File
@@ -33,6 +33,30 @@ fn ai_subpages_are_classified_and_map_to_their_backing_pages() {
}
}
#[test]
fn provider_subpages_are_classified_and_map_to_ai_backing_page() {
assert_eq!(
SettingsSection::provider_subpages(),
&[
SettingsSection::ProviderOpenAI,
SettingsSection::ProviderLiteLLM,
SettingsSection::ProviderChatGPTSubscription,
SettingsSection::ProviderBedrock,
SettingsSection::ProviderACP,
]
);
for section in SettingsSection::provider_subpages() {
assert!(section.is_provider_subpage());
assert!(section.is_subpage());
assert_eq!(
section.parent_page_section(),
SettingsSection::AI,
"{section:?} should use the AI backing page"
);
}
assert!(!SettingsSection::AI.is_provider_subpage());
}
#[test]
fn code_subpages_are_classified_and_map_to_code() {
assert_eq!(
@@ -86,8 +110,15 @@ fn current_settings_display_names_round_trip() {
SettingsSection::ThirdPartyCLIAgents,
"Third party CLI agents",
),
(SettingsSection::Models, "Models"),
(SettingsSection::Experiments, "Experiments"),
(SettingsSection::ProviderOpenAI, "OpenAI"),
(SettingsSection::ProviderLiteLLM, "LiteLLM"),
(
SettingsSection::ProviderChatGPTSubscription,
"ChatGPT Subscription",
),
(SettingsSection::ProviderBedrock, "Bedrock"),
(SettingsSection::ProviderACP, "ACP"),
(SettingsSection::CodeIndexing, "Indexing and projects"),
(
SettingsSection::EditorAndCodeReview,
@@ -110,13 +141,23 @@ fn legacy_settings_names_remain_parseable() {
("AgentProfiles", SettingsSection::AgentProfiles),
("AgentMCPServers", SettingsSection::AgentMCPServers),
("ThirdPartyCLIAgents", SettingsSection::ThirdPartyCLIAgents),
("AWS Bedrock", SettingsSection::Models),
("Bedrock", SettingsSection::Models),
("OpenAI / LiteLLM", SettingsSection::Models),
("OpenAI", SettingsSection::Models),
("Agent runtimes", SettingsSection::Models),
("Agent Client Protocol", SettingsSection::Models),
("ACP", SettingsSection::Models),
("AWS Bedrock", SettingsSection::ProviderBedrock),
("Bedrock", SettingsSection::ProviderBedrock),
("OpenAI / LiteLLM", SettingsSection::ProviderLiteLLM),
("LiteLLM", SettingsSection::ProviderLiteLLM),
("OpenAI", SettingsSection::ProviderOpenAI),
("Models", SettingsSection::ProviderOpenAI),
("Agent runtimes", SettingsSection::ProviderOpenAI),
(
"ChatGPTSubscription",
SettingsSection::ProviderChatGPTSubscription,
),
(
"ChatGPT Subscription",
SettingsSection::ProviderChatGPTSubscription,
),
("Agent Client Protocol", SettingsSection::ProviderACP),
("ACP", SettingsSection::ProviderACP),
("CodeIndexing", SettingsSection::CodeIndexing),
("EditorAndCodeReview", SettingsSection::EditorAndCodeReview),
] {
@@ -139,6 +180,10 @@ fn realistic_nav_items() -> Vec<SettingsNavItem> {
"Agents",
SettingsSection::ai_subpages().to_vec(),
)),
SettingsNavItem::Umbrella(SettingsUmbrella::new(
"Providers",
SettingsSection::provider_subpages().to_vec(),
)),
SettingsNavItem::Umbrella(SettingsUmbrella::new(
"Code",
SettingsSection::code_subpages().to_vec(),
@@ -166,7 +211,7 @@ fn collapsed_umbrellas_each_form_one_navigation_stop() {
let nav_items = realistic_nav_items();
let stops = build_nav_stops(&nav_items, |_| true);
assert_eq!(stops.len(), 10);
assert_eq!(stops.len(), 11);
assert_eq!(
stops[0],
NavStop::CollapsedUmbrella {
@@ -179,12 +224,20 @@ fn collapsed_umbrellas_each_form_one_navigation_stop() {
stops[1],
NavStop::CollapsedUmbrella {
nav_index: 1,
first_subpage: SettingsSection::ProviderOpenAI,
last_subpage: SettingsSection::ProviderACP,
}
);
assert_eq!(
stops[2],
NavStop::CollapsedUmbrella {
nav_index: 2,
first_subpage: SettingsSection::CodeIndexing,
last_subpage: SettingsSection::EditorAndCodeReview,
}
);
assert_eq!(stops[2], NavStop::Section(SettingsSection::Appearance));
assert_eq!(stops[9], NavStop::Section(SettingsSection::Scripting));
assert_eq!(stops[3], NavStop::Section(SettingsSection::Appearance));
assert_eq!(stops[10], NavStop::Section(SettingsSection::Scripting));
}
#[test]
@@ -207,8 +260,8 @@ fn expanded_umbrella_has_one_stop_per_visible_subpage() {
stops[expected_ai_sections.len()],
NavStop::CollapsedUmbrella {
nav_index: 1,
first_subpage: SettingsSection::CodeIndexing,
last_subpage: SettingsSection::EditorAndCodeReview,
first_subpage: SettingsSection::ProviderOpenAI,
last_subpage: SettingsSection::ProviderACP,
}
);
}
@@ -244,6 +297,9 @@ fn umbrella_without_visible_subpages_is_skipped() {
assert!(stops
.iter()
.any(|stop| matches!(stop, NavStop::CollapsedUmbrella { nav_index: 1, .. })));
assert!(stops
.iter()
.any(|stop| matches!(stop, NavStop::CollapsedUmbrella { nav_index: 2, .. })));
}
#[test]
@@ -263,16 +319,20 @@ fn current_stop_matches_sections_and_collapsed_umbrella_children() {
assert_eq!(
current_stop_index(&stops, &nav_items, SettingsSection::Appearance),
Some(2)
Some(3)
);
assert_eq!(
current_stop_index(&stops, &nav_items, SettingsSection::Knowledge),
Some(0)
);
assert_eq!(
current_stop_index(&stops, &nav_items, SettingsSection::CodeIndexing),
current_stop_index(&stops, &nav_items, SettingsSection::ProviderLiteLLM),
Some(1)
);
assert_eq!(
current_stop_index(&stops, &nav_items, SettingsSection::CodeIndexing),
Some(2)
);
}
#[test]
@@ -355,6 +415,6 @@ fn cycling_leaves_an_expanded_umbrella_after_its_last_subpage() {
SettingsSection::Experiments,
CycleDirection::Down,
),
SettingsSection::CodeIndexing
SettingsSection::ProviderOpenAI
);
}
@@ -2,10 +2,11 @@ use galaxy_cli::agent::Harness;
use galaxy_core::ui::theme::Fill;
use galaxyui::elements::{
Border, ChildView, ClippedScrollStateHandle, ClippedScrollable, ConstrainedBox, Container,
CornerRadius, CrossAxisAlignment, Flex, FormattedTextElement, MainAxisAlignment, MainAxisSize,
MouseStateHandle, Padding, ParentElement, Radius, ScrollbarWidth, Text,
CornerRadius, CrossAxisAlignment, Flex, FormattedTextElement, Hoverable, MainAxisAlignment,
MainAxisSize, MouseStateHandle, Padding, ParentElement, Radius, ScrollbarWidth, Text,
};
use galaxyui::fonts::{Properties, Weight};
use galaxyui::platform::Cursor;
use galaxyui::text_layout::ClipConfig;
use galaxyui::ui_components::button::ButtonVariant;
use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
@@ -23,7 +24,6 @@ use crate::appearance::Appearance;
use crate::editor::{
EditorView, Event as EditorEvent, SingleLineEditorOptions, TextColors, TextOptions,
};
use crate::modal::{Modal, ModalViewState};
use crate::settings::ai::{
AcpConfigOptionSettings, BedrockAuthMethod, BedrockModelConfig, ModelCapabilityOverride,
OpenAIModelConfig, OpenAIProviderConfig, OpenAIProviderKind,
@@ -33,14 +33,12 @@ use crate::view_components::action_button::{
ActionButton, ButtonSize, NakedTheme, PrimaryTheme, SecondaryTheme,
};
const MODAL_WIDTH: f32 = 900.;
const MODAL_HEIGHT: f32 = 700.;
const BODY_HEIGHT: f32 = 630.;
const SETUP_WIDTH: f32 = 900.;
const INPUT_FONT_SIZE: f32 = 12.;
const MODEL_LOGO_SIZE: f32 = 20.;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ProviderSetupStep {
pub(crate) enum ProviderSetupStep {
ProviderType,
Configure,
Discover,
@@ -49,8 +47,9 @@ enum ProviderSetupStep {
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ProviderSetupProviderType {
OpenAI,
LiteLLM,
ChatGPTSubscription,
OpenAICompatible,
Anthropic,
Gemini,
VertexAI,
@@ -59,41 +58,26 @@ pub enum ProviderSetupProviderType {
}
const PROVIDER_TYPE_OPTIONS: &[(ProviderSetupProviderType, &str, &str)] = &[
(
ProviderSetupProviderType::OpenAI,
"OpenAI",
"Connect directly to OpenAI's API with an API key.",
),
(
ProviderSetupProviderType::LiteLLM,
"LiteLLM",
"Connect LiteLLM and use its richer model metadata APIs during discovery.",
),
(
ProviderSetupProviderType::ChatGPTSubscription,
"ChatGPT subscription",
"Use your ChatGPT Plus or Pro subscription with native OAuth.",
),
(
ProviderSetupProviderType::OpenAICompatible,
"OpenAI-compatible API",
"Connect LiteLLM, Ollama, vLLM, or another compatible endpoint.",
),
(
ProviderSetupProviderType::Anthropic,
"Anthropic",
"Connect directly to Anthropic's native Messages API with an API key.",
),
(
ProviderSetupProviderType::Gemini,
"Google Gemini",
"Connect directly to Google's Gemini API with an API key.",
),
(
ProviderSetupProviderType::VertexAI,
"Google Vertex AI",
"Use Google Cloud Application Default Credentials for Vertex-hosted Gemini models.",
),
(
ProviderSetupProviderType::Bedrock,
"AWS Bedrock",
"Use the AWS Bedrock credentials and model configuration already managed by Galaxy.",
),
(
ProviderSetupProviderType::Acp,
"ACP agent runtime",
"Use a session-oriented ACP agent that owns its model and authentication.",
),
];
#[derive(Clone, Debug)]
@@ -165,7 +149,7 @@ impl CapabilityKey {
}
}
pub enum ProviderSetupModalBodyEvent {
pub enum ProviderSetupViewEvent {
Close,
RequestAcpDiscovery(AcpProviderDraft),
SaveOpenAI {
@@ -177,8 +161,9 @@ pub enum ProviderSetupModalBodyEvent {
}
#[derive(Clone, Debug, PartialEq)]
pub enum ProviderSetupModalBodyAction {
pub enum ProviderSetupViewAction {
SelectProvider(ProviderSetupProviderType),
JumpToStep(ProviderSetupStep),
Next,
Back,
Cancel,
@@ -193,12 +178,11 @@ pub enum ProviderSetupModalBodyAction {
SelectAcpAgent(String),
}
pub type ProviderSetupModalState = ModalViewState<Modal<ProviderSetupModalBody>>;
pub struct ProviderSetupModalBody {
pub struct ProviderSetupView {
step: ProviderSetupStep,
editing_index: Option<usize>,
provider_type: ProviderSetupProviderType,
provider_type_locked: bool,
draft_name: String,
draft_base_url: String,
draft_api_key: Option<String>,
@@ -233,12 +217,13 @@ pub struct ProviderSetupModalBody {
model_context_editors: Vec<ViewHandle<EditorView>>,
provider_type_scroll_state: ClippedScrollStateHandle,
models_scroll_state: ClippedScrollStateHandle,
step_tab_mouse_states: Vec<MouseStateHandle>,
back_button: ViewHandle<ActionButton>,
cancel_button: ViewHandle<ActionButton>,
next_button: ViewHandle<ActionButton>,
}
impl ProviderSetupModalBody {
impl ProviderSetupView {
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
let provider_type_buttons = PROVIDER_TYPE_OPTIONS
.iter()
@@ -249,9 +234,9 @@ impl ProviderSetupModalBody {
ActionButton::new(label, NakedTheme)
.with_full_width(true)
.on_click(move |ctx| {
ctx.dispatch_typed_action(
ProviderSetupModalBodyAction::SelectProvider(kind),
);
ctx.dispatch_typed_action(ProviderSetupViewAction::SelectProvider(
kind,
));
})
})
})
@@ -281,9 +266,9 @@ impl ProviderSetupModalBody {
.on_click({
let id = id.clone();
move |ctx| {
ctx.dispatch_typed_action(
ProviderSetupModalBodyAction::SelectAcpAgent(id.clone()),
);
ctx.dispatch_typed_action(ProviderSetupViewAction::SelectAcpAgent(
id.clone(),
));
}
})
})
@@ -293,7 +278,7 @@ impl ProviderSetupModalBody {
ActionButton::new("Custom", NakedTheme)
.with_full_width(true)
.on_click(|ctx| {
ctx.dispatch_typed_action(ProviderSetupModalBodyAction::SelectAcpAgent(
ctx.dispatch_typed_action(ProviderSetupViewAction::SelectAcpAgent(
"custom".to_owned(),
));
})
@@ -308,9 +293,7 @@ impl ProviderSetupModalBody {
.map(|method| {
ctx.add_typed_action_view(move |_| {
ActionButton::new(method.display_name(), NakedTheme).on_click(move |ctx| {
ctx.dispatch_typed_action(ProviderSetupModalBodyAction::SelectBedrockAuth(
method,
));
ctx.dispatch_typed_action(ProviderSetupViewAction::SelectBedrockAuth(method));
})
})
})
@@ -397,24 +380,25 @@ impl ProviderSetupModalBody {
let back_button = ctx.add_typed_action_view(|_| {
ActionButton::new("Back", NakedTheme).on_click(|ctx| {
ctx.dispatch_typed_action(ProviderSetupModalBodyAction::Back);
ctx.dispatch_typed_action(ProviderSetupViewAction::Back);
})
});
let cancel_button = ctx.add_typed_action_view(|_| {
ActionButton::new("Cancel", NakedTheme).on_click(|ctx| {
ctx.dispatch_typed_action(ProviderSetupModalBodyAction::Cancel);
ctx.dispatch_typed_action(ProviderSetupViewAction::Cancel);
})
});
let next_button = ctx.add_typed_action_view(|_| {
ActionButton::new("Next", PrimaryTheme).on_click(|ctx| {
ctx.dispatch_typed_action(ProviderSetupModalBodyAction::Next);
ctx.dispatch_typed_action(ProviderSetupViewAction::Next);
})
});
Self {
step: ProviderSetupStep::ProviderType,
editing_index: None,
provider_type: ProviderSetupProviderType::OpenAICompatible,
provider_type: ProviderSetupProviderType::LiteLLM,
provider_type_locked: false,
draft_name: String::new(),
draft_base_url: String::new(),
draft_api_key: None,
@@ -466,6 +450,7 @@ impl ProviderSetupModalBody {
model_context_editors: Vec::new(),
provider_type_scroll_state: ClippedScrollStateHandle::default(),
models_scroll_state: ClippedScrollStateHandle::default(),
step_tab_mouse_states: (0..4).map(|_| MouseStateHandle::default()).collect(),
back_button,
cancel_button,
next_button,
@@ -499,12 +484,43 @@ impl ProviderSetupModalBody {
})
}
pub fn begin_create(&mut self, ctx: &mut ViewContext<Self>) {
self.step = ProviderSetupStep::ProviderType;
fn default_name(provider_type: ProviderSetupProviderType) -> &'static str {
match provider_type {
ProviderSetupProviderType::OpenAI => "OpenAI",
ProviderSetupProviderType::LiteLLM => "LiteLLM",
ProviderSetupProviderType::ChatGPTSubscription => "ChatGPT Subscription",
ProviderSetupProviderType::Anthropic => "Anthropic",
ProviderSetupProviderType::Gemini => "Google Gemini",
ProviderSetupProviderType::VertexAI => "Google Vertex AI",
ProviderSetupProviderType::Bedrock => "AWS Bedrock",
ProviderSetupProviderType::Acp => "ACP agent runtime",
}
}
fn default_base_url(provider_type: ProviderSetupProviderType) -> &'static str {
match provider_type {
ProviderSetupProviderType::OpenAI => "https://api.openai.com/v1",
ProviderSetupProviderType::LiteLLM
| ProviderSetupProviderType::ChatGPTSubscription
| ProviderSetupProviderType::Anthropic
| ProviderSetupProviderType::Gemini
| ProviderSetupProviderType::VertexAI
| ProviderSetupProviderType::Bedrock
| ProviderSetupProviderType::Acp => "",
}
}
pub fn begin_create(
&mut self,
provider_type: ProviderSetupProviderType,
ctx: &mut ViewContext<Self>,
) {
self.step = ProviderSetupStep::Configure;
self.editing_index = None;
self.provider_type = ProviderSetupProviderType::OpenAICompatible;
self.draft_name.clear();
self.draft_base_url.clear();
self.provider_type = provider_type;
self.provider_type_locked = true;
self.draft_name = Self::default_name(provider_type).to_string();
self.draft_base_url = Self::default_base_url(provider_type).to_string();
self.draft_api_key = None;
self.draft_project_id.clear();
self.draft_location = "global".to_string();
@@ -549,11 +565,13 @@ impl ProviderSetupModalBody {
// send the user through credentials or model discovery again.
self.step = ProviderSetupStep::Models;
self.editing_index = Some(editing_index);
self.provider_type_locked = true;
self.provider_type = match provider.kind {
OpenAIProviderKind::OpenAI => ProviderSetupProviderType::OpenAI,
OpenAIProviderKind::LiteLLM => ProviderSetupProviderType::LiteLLM,
OpenAIProviderKind::ChatGPTSubscription => {
ProviderSetupProviderType::ChatGPTSubscription
}
OpenAIProviderKind::OpenAICompatible => ProviderSetupProviderType::OpenAICompatible,
OpenAIProviderKind::Anthropic => ProviderSetupProviderType::Anthropic,
OpenAIProviderKind::Gemini => ProviderSetupProviderType::Gemini,
OpenAIProviderKind::VertexAI => ProviderSetupProviderType::VertexAI,
@@ -578,6 +596,7 @@ impl ProviderSetupModalBody {
pub fn begin_edit_bedrock(&mut self, draft: BedrockProviderDraft, ctx: &mut ViewContext<Self>) {
self.step = ProviderSetupStep::Configure;
self.editing_index = None;
self.provider_type_locked = true;
self.provider_type = ProviderSetupProviderType::Bedrock;
self.draft_name = draft.name.clone();
self.draft_bedrock = draft;
@@ -599,6 +618,7 @@ impl ProviderSetupModalBody {
ProviderSetupStep::Models
};
self.editing_index = None;
self.provider_type_locked = true;
self.provider_type = ProviderSetupProviderType::Acp;
self.draft_name = draft.name.clone();
self.draft_acp = draft;
@@ -744,7 +764,7 @@ impl ProviderSetupModalBody {
.with_size(ButtonSize::XSmall)
.on_click(move |ctx| {
ctx.dispatch_typed_action(
ProviderSetupModalBodyAction::CycleModelCapability(index, key),
ProviderSetupViewAction::CycleModelCapability(index, key),
);
})
})
@@ -793,27 +813,7 @@ impl ProviderSetupModalBody {
fn update_next_button(&self, ctx: &mut ViewContext<Self>) {
let (label, disabled) = match self.step {
ProviderSetupStep::ProviderType => ("Next", false),
ProviderSetupStep::Configure => {
let disabled = match self.provider_type {
ProviderSetupProviderType::OpenAICompatible => {
self.draft_base_url.trim().is_empty()
}
ProviderSetupProviderType::Anthropic | ProviderSetupProviderType::Gemini => {
self.draft_api_key
.as_deref()
.is_none_or(|key| key.trim().is_empty())
}
ProviderSetupProviderType::VertexAI => self.draft_project_id.trim().is_empty(),
ProviderSetupProviderType::Acp => {
self.draft_acp.agent_id.trim().is_empty()
|| (self.draft_acp.agent_id.eq_ignore_ascii_case("custom")
&& self.draft_acp.command.trim().is_empty())
}
ProviderSetupProviderType::ChatGPTSubscription
| ProviderSetupProviderType::Bedrock => false,
};
("Next", disabled)
}
ProviderSetupStep::Configure => ("Next", !self.is_configure_valid()),
ProviderSetupStep::Discover => (
if matches!(self.discovery_state, DiscoveryState::Failed(_)) {
"Retry"
@@ -823,7 +823,8 @@ impl ProviderSetupModalBody {
!matches!(self.discovery_state, DiscoveryState::Failed(_)),
),
ProviderSetupStep::Models => match self.provider_type {
ProviderSetupProviderType::OpenAICompatible
ProviderSetupProviderType::OpenAI
| ProviderSetupProviderType::LiteLLM
| ProviderSetupProviderType::ChatGPTSubscription
| ProviderSetupProviderType::Anthropic
| ProviderSetupProviderType::Gemini
@@ -851,12 +852,14 @@ impl ProviderSetupModalBody {
fn draft_provider(&self) -> OpenAIProviderConfig {
OpenAIProviderConfig {
kind: match self.provider_type {
ProviderSetupProviderType::OpenAI => OpenAIProviderKind::OpenAI,
ProviderSetupProviderType::LiteLLM => OpenAIProviderKind::LiteLLM,
ProviderSetupProviderType::ChatGPTSubscription => {
OpenAIProviderKind::ChatGPTSubscription
}
ProviderSetupProviderType::OpenAICompatible
| ProviderSetupProviderType::Bedrock
| ProviderSetupProviderType::Acp => OpenAIProviderKind::OpenAICompatible,
ProviderSetupProviderType::Bedrock | ProviderSetupProviderType::Acp => {
OpenAIProviderKind::LiteLLM
}
ProviderSetupProviderType::Anthropic => OpenAIProviderKind::Anthropic,
ProviderSetupProviderType::Gemini => OpenAIProviderKind::Gemini,
ProviderSetupProviderType::VertexAI => OpenAIProviderKind::VertexAI,
@@ -876,7 +879,8 @@ impl ProviderSetupModalBody {
},
api_key: matches!(
self.provider_type,
ProviderSetupProviderType::OpenAICompatible
ProviderSetupProviderType::OpenAI
| ProviderSetupProviderType::LiteLLM
| ProviderSetupProviderType::Anthropic
| ProviderSetupProviderType::Gemini
)
@@ -903,6 +907,38 @@ impl ProviderSetupModalBody {
}
}
fn jump_to_step(&mut self, step: ProviderSetupStep, ctx: &mut ViewContext<Self>) {
if !self.can_jump_to_step(step) {
return;
}
match step {
ProviderSetupStep::ProviderType => {
self.step = ProviderSetupStep::ProviderType;
self.discovery_state = DiscoveryState::Idle;
self.update_next_button(ctx);
ctx.notify();
}
ProviderSetupStep::Configure => {
self.step = ProviderSetupStep::Configure;
self.discovery_state = DiscoveryState::Idle;
self.update_next_button(ctx);
ctx.notify();
}
ProviderSetupStep::Discover => {
self.begin_discovery(ctx);
}
ProviderSetupStep::Models => {
self.step = ProviderSetupStep::Models;
self.discovery_state = DiscoveryState::Idle;
self.sync_model_switches(ctx);
self.update_next_button(ctx);
ctx.focus(&self.name_editor);
ctx.notify();
}
}
}
fn begin_discovery(&mut self, ctx: &mut ViewContext<Self>) {
self.step = ProviderSetupStep::Discover;
self.discovery_state = DiscoveryState::Loading;
@@ -948,12 +984,13 @@ impl ProviderSetupModalBody {
return;
}
ProviderSetupProviderType::Acp => {
ctx.emit(ProviderSetupModalBodyEvent::RequestAcpDiscovery(
ctx.emit(ProviderSetupViewEvent::RequestAcpDiscovery(
self.draft_acp.clone(),
));
return;
}
ProviderSetupProviderType::OpenAICompatible
ProviderSetupProviderType::OpenAI
| ProviderSetupProviderType::LiteLLM
| ProviderSetupProviderType::Anthropic
| ProviderSetupProviderType::Gemini
| ProviderSetupProviderType::VertexAI => {}
@@ -1158,7 +1195,7 @@ impl ProviderSetupModalBody {
.with_text_label("Connect ChatGPT".to_owned())
.build()
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(ProviderSetupModalBodyAction::ConnectChatGPT);
ctx.dispatch_typed_action(ProviderSetupViewAction::ConnectChatGPT);
})
.finish(),
);
@@ -1210,12 +1247,26 @@ impl ProviderSetupModalBody {
ProviderSetupProviderType::ChatGPTSubscription => {
children.push(self.render_chatgpt_auth(appearance, app));
}
ProviderSetupProviderType::OpenAICompatible => {
ProviderSetupProviderType::OpenAI => {
children.push(self.render_input(appearance, "Base URL", &self.base_url_editor));
children.push(self.render_input(appearance, "API key", &self.api_key_editor));
children.push(
Text::new(
"The API key is stored locally and is never synced to the cloud.",
"The API key is stored locally and is never synced to the cloud. Models will be discovered from OpenAI's /models endpoint.",
appearance.ui_font_family(),
INPUT_FONT_SIZE,
)
.with_color(appearance.theme().nonactive_ui_text_color().into())
.soft_wrap(true)
.finish(),
);
}
ProviderSetupProviderType::LiteLLM => {
children.push(self.render_input(appearance, "Base URL", &self.base_url_editor));
children.push(self.render_input(appearance, "API key", &self.api_key_editor));
children.push(
Text::new(
"The API key is stored locally and is never synced to the cloud. LiteLLM model discovery uses /model/info for rich metadata, then falls back to /models.",
appearance.ui_font_family(),
INPUT_FONT_SIZE,
)
@@ -1301,7 +1352,7 @@ impl ProviderSetupModalBody {
.build()
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(
ProviderSetupModalBodyAction::ToggleBedrockAutoLogin,
ProviderSetupViewAction::ToggleBedrockAutoLogin,
);
})
.finish(),
@@ -1334,7 +1385,7 @@ impl ProviderSetupModalBody {
.build()
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(
ProviderSetupModalBodyAction::ToggleBedrockCrossRegion,
ProviderSetupViewAction::ToggleBedrockCrossRegion,
);
})
.finish(),
@@ -1462,7 +1513,7 @@ impl ProviderSetupModalBody {
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(6.)))
.finish(),
)
.with_width(MODAL_WIDTH - 56.)
.with_width(SETUP_WIDTH - 56.)
.with_max_height(430.)
.finish()
}
@@ -1470,7 +1521,8 @@ impl ProviderSetupModalBody {
fn model_logo(&self) -> (Icon, ColorU) {
match self.provider_type {
ProviderSetupProviderType::ChatGPTSubscription
| ProviderSetupProviderType::OpenAICompatible => {
| ProviderSetupProviderType::OpenAI
| ProviderSetupProviderType::LiteLLM => {
(Icon::OpenAILogo, crate::terminal::cli_agent::OPENAI_COLOR)
}
ProviderSetupProviderType::Anthropic => {
@@ -1636,9 +1688,9 @@ impl ProviderSetupModalBody {
.check(model.enabled)
.build()
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(
ProviderSetupModalBodyAction::ToggleModel(index),
);
ctx.dispatch_typed_action(ProviderSetupViewAction::ToggleModel(
index,
));
})
.finish(),
)
@@ -1828,7 +1880,7 @@ impl ProviderSetupModalBody {
.with_main_axis_alignment(MainAxisAlignment::End)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_spacing(8.);
if self.step != ProviderSetupStep::ProviderType {
if self.can_go_back() {
footer = footer.with_child(ChildView::new(&self.back_button).finish());
}
footer = footer.with_child(ChildView::new(&self.cancel_button).finish());
@@ -1840,44 +1892,178 @@ impl ProviderSetupModalBody {
footer.finish()
}
fn render_step_indicator(&self, appearance: &Appearance) -> Box<dyn galaxyui::Element> {
let steps = [
(ProviderSetupStep::ProviderType, "Provider"),
(ProviderSetupStep::Configure, "Configure"),
(ProviderSetupStep::Discover, "Test"),
(ProviderSetupStep::Models, "Models"),
];
Flex::row()
.with_spacing(10.)
.with_children(steps.into_iter().map(|(step, label)| {
let active = self.step == step;
Text::new(label, appearance.ui_font_family(), INPUT_FONT_SIZE)
.with_color(
if active {
appearance.theme().accent()
} else {
appearance.theme().nonactive_ui_text_color()
fn can_go_back(&self) -> bool {
match self.step {
ProviderSetupStep::ProviderType => false,
ProviderSetupStep::Configure => !self.provider_type_locked,
ProviderSetupStep::Discover | ProviderSetupStep::Models => true,
}
.into(),
}
fn is_configure_valid(&self) -> bool {
match self.provider_type {
ProviderSetupProviderType::OpenAI => {
!self.draft_base_url.trim().is_empty()
&& self
.draft_api_key
.as_deref()
.is_some_and(|key| !key.trim().is_empty())
}
ProviderSetupProviderType::LiteLLM => !self.draft_base_url.trim().is_empty(),
ProviderSetupProviderType::Anthropic | ProviderSetupProviderType::Gemini => self
.draft_api_key
.as_deref()
.is_some_and(|key| !key.trim().is_empty()),
ProviderSetupProviderType::VertexAI => !self.draft_project_id.trim().is_empty(),
ProviderSetupProviderType::Acp => {
!self.draft_acp.agent_id.trim().is_empty()
&& (!self.draft_acp.agent_id.eq_ignore_ascii_case("custom")
|| !self.draft_acp.command.trim().is_empty())
}
ProviderSetupProviderType::ChatGPTSubscription | ProviderSetupProviderType::Bedrock => {
true
}
}
}
fn has_model_catalog(&self) -> bool {
match self.provider_type {
ProviderSetupProviderType::OpenAI
| ProviderSetupProviderType::LiteLLM
| ProviderSetupProviderType::ChatGPTSubscription
| ProviderSetupProviderType::Anthropic
| ProviderSetupProviderType::Gemini
| ProviderSetupProviderType::VertexAI => !self.draft_models.is_empty(),
ProviderSetupProviderType::Bedrock => !self.draft_bedrock.models.is_empty(),
ProviderSetupProviderType::Acp => !self.draft_acp.config_options.is_empty(),
}
}
fn step_tab_index(step: ProviderSetupStep) -> usize {
match step {
ProviderSetupStep::ProviderType => 0,
ProviderSetupStep::Configure => 1,
ProviderSetupStep::Discover => 2,
ProviderSetupStep::Models => 3,
}
}
fn can_jump_to_step(&self, step: ProviderSetupStep) -> bool {
if self.step == step {
return false;
}
match step {
ProviderSetupStep::ProviderType => !self.provider_type_locked,
ProviderSetupStep::Configure => true,
ProviderSetupStep::Discover => {
!matches!(self.discovery_state, DiscoveryState::Loading)
&& self.is_configure_valid()
}
ProviderSetupStep::Models => self.has_model_catalog(),
}
}
fn render_step_tab(
&self,
step: ProviderSetupStep,
label: &'static str,
appearance: &Appearance,
) -> Box<dyn galaxyui::Element> {
let active = self.step == step;
let enabled = self.can_jump_to_step(step);
let Some(mouse_state) = self
.step_tab_mouse_states
.get(Self::step_tab_index(step))
.cloned()
else {
return Text::new(label, appearance.ui_font_family(), INPUT_FONT_SIZE)
.with_color(appearance.theme().disabled_ui_text_color().into())
.finish();
};
let tab = Hoverable::new(mouse_state, move |mouse_state| {
let theme = appearance.theme();
let text_color = if active {
theme.accent()
} else if enabled {
theme.nonactive_ui_text_color()
} else {
theme.disabled_ui_text_color()
};
let mut container = Container::new(
Text::new_inline(
label.to_string(),
appearance.ui_font_family(),
INPUT_FONT_SIZE,
)
.with_color(text_color.into())
.with_style(Properties::default().weight(if active {
Weight::Bold
} else {
Weight::Normal
}))
.finish(),
)
.with_horizontal_padding(10.)
.with_vertical_padding(6.)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(6.)));
if active {
container = container.with_background(theme.surface_overlay_1());
} else if enabled && mouse_state.is_hovered() {
container = container.with_background(theme.surface_overlay_2());
}
container.finish()
});
if enabled {
tab.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(ProviderSetupViewAction::JumpToStep(step));
})
.with_cursor(Cursor::PointingHand)
.finish()
}))
} else {
tab.finish()
}
}
fn render_step_indicator(&self, appearance: &Appearance) -> Box<dyn galaxyui::Element> {
let locked_steps = [
(ProviderSetupStep::Configure, "Configure"),
(ProviderSetupStep::Discover, "Test"),
(ProviderSetupStep::Models, "Models"),
];
let selectable_steps = [
(ProviderSetupStep::ProviderType, "Provider"),
(ProviderSetupStep::Configure, "Configure"),
(ProviderSetupStep::Discover, "Test"),
(ProviderSetupStep::Models, "Models"),
];
let steps: &[(ProviderSetupStep, &str)] = if self.provider_type_locked {
&locked_steps
} else {
&selectable_steps
};
Flex::row()
.with_spacing(10.)
.with_children(
steps
.iter()
.map(|(step, label)| self.render_step_tab(*step, label, appearance)),
)
.finish()
}
}
impl Entity for ProviderSetupModalBody {
type Event = ProviderSetupModalBodyEvent;
impl Entity for ProviderSetupView {
type Event = ProviderSetupViewEvent;
}
impl View for ProviderSetupModalBody {
impl View for ProviderSetupView {
fn ui_name() -> &'static str {
"ProviderSetupModalBody"
"ProviderSetupView"
}
fn render(&self, app: &AppContext) -> Box<dyn galaxyui::Element> {
@@ -1897,27 +2083,30 @@ impl View for ProviderSetupModalBody {
}
}
impl TypedActionView for ProviderSetupModalBody {
type Action = ProviderSetupModalBodyAction;
impl TypedActionView for ProviderSetupView {
type Action = ProviderSetupViewAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
ProviderSetupModalBodyAction::SelectProvider(kind) => {
ProviderSetupViewAction::SelectProvider(kind) => {
if self.provider_type != *kind {
self.draft_models.clear();
self.discovery_state = DiscoveryState::Idle;
}
self.provider_type = *kind;
if *kind == ProviderSetupProviderType::ChatGPTSubscription {
self.draft_base_url.clear();
self.draft_name = Self::default_name(*kind).to_string();
self.draft_base_url = Self::default_base_url(*kind).to_string();
if matches!(*kind, ProviderSetupProviderType::ChatGPTSubscription) {
self.draft_api_key = None;
}
self.sync_editors(ctx);
self.sync_provider_type_buttons(ctx);
self.sync_bedrock_auth_buttons(ctx);
self.update_next_button(ctx);
ctx.notify();
}
ProviderSetupModalBodyAction::Next => match self.step {
ProviderSetupViewAction::JumpToStep(step) => self.jump_to_step(*step, ctx),
ProviderSetupViewAction::Next => match self.step {
ProviderSetupStep::ProviderType => {
self.step = ProviderSetupStep::Configure;
self.update_next_button(ctx);
@@ -1930,7 +2119,8 @@ impl TypedActionView for ProviderSetupModalBody {
}
}
ProviderSetupStep::Models => match self.provider_type {
ProviderSetupProviderType::OpenAICompatible
ProviderSetupProviderType::OpenAI
| ProviderSetupProviderType::LiteLLM
| ProviderSetupProviderType::ChatGPTSubscription
| ProviderSetupProviderType::Anthropic
| ProviderSetupProviderType::Gemini
@@ -1940,7 +2130,7 @@ impl TypedActionView for ProviderSetupModalBody {
{
return;
}
ctx.emit(ProviderSetupModalBodyEvent::SaveOpenAI {
ctx.emit(ProviderSetupViewEvent::SaveOpenAI {
editing_index: self.editing_index,
provider: self.draft_provider(),
});
@@ -1952,7 +2142,7 @@ impl TypedActionView for ProviderSetupModalBody {
}
let mut draft = self.draft_bedrock.clone();
draft.name = self.draft_name.trim().to_string();
ctx.emit(ProviderSetupModalBodyEvent::SaveBedrock(draft));
ctx.emit(ProviderSetupViewEvent::SaveBedrock(draft));
}
ProviderSetupProviderType::Acp => {
if self.draft_name.trim().is_empty() {
@@ -1960,13 +2150,16 @@ impl TypedActionView for ProviderSetupModalBody {
}
let mut draft = self.draft_acp.clone();
draft.name = self.draft_name.trim().to_string();
ctx.emit(ProviderSetupModalBodyEvent::SaveAcp(draft));
ctx.emit(ProviderSetupViewEvent::SaveAcp(draft));
}
},
},
ProviderSetupModalBodyAction::Back => match self.step {
ProviderSetupViewAction::Back => match self.step {
ProviderSetupStep::ProviderType => {}
ProviderSetupStep::Configure => {
if self.provider_type_locked {
return;
}
self.step = ProviderSetupStep::ProviderType;
self.update_next_button(ctx);
ctx.notify();
@@ -1983,10 +2176,10 @@ impl TypedActionView for ProviderSetupModalBody {
ctx.notify();
}
},
ProviderSetupModalBodyAction::Cancel => {
ctx.emit(ProviderSetupModalBodyEvent::Close);
ProviderSetupViewAction::Cancel => {
ctx.emit(ProviderSetupViewEvent::Close);
}
ProviderSetupModalBodyAction::SelectAcpAgent(agent_id) => {
ProviderSetupViewAction::SelectAcpAgent(agent_id) => {
self.draft_acp.agent_id = agent_id.clone();
if !agent_id.eq_ignore_ascii_case("custom") {
self.draft_acp.command.clear();
@@ -1997,14 +2190,14 @@ impl TypedActionView for ProviderSetupModalBody {
self.update_next_button(ctx);
ctx.notify();
}
ProviderSetupModalBodyAction::ToggleModel(index) => {
ProviderSetupViewAction::ToggleModel(index) => {
if let Some(model) = self.draft_models.get_mut(*index) {
model.enabled = !model.enabled;
self.update_next_button(ctx);
ctx.notify();
}
}
ProviderSetupModalBodyAction::CycleModelCapability(index, capability) => {
ProviderSetupViewAction::CycleModelCapability(index, capability) => {
if let Some(model) = self.draft_models.get_mut(*index) {
let key = capability.setting_key().to_string();
let next = model.capability_override(&key).next();
@@ -2014,28 +2207,28 @@ impl TypedActionView for ProviderSetupModalBody {
ctx.notify();
}
}
ProviderSetupModalBodyAction::ConnectChatGPT => {
ProviderSetupViewAction::ConnectChatGPT => {
#[cfg(not(target_family = "wasm"))]
ChatGPTAuthModel::handle(ctx).update(ctx, |model, ctx| model.connect(ctx));
}
ProviderSetupModalBodyAction::OpenChatGPTDevicePage => {
ProviderSetupViewAction::OpenChatGPTDevicePage => {
// No-op: device-code flow removed in favor of browser OAuth.
}
ProviderSetupModalBodyAction::CopyChatGPTDeviceCode => {
ProviderSetupViewAction::CopyChatGPTDeviceCode => {
// No-op: device-code flow removed in favor of browser OAuth.
}
ProviderSetupModalBodyAction::SelectBedrockAuth(method) => {
ProviderSetupViewAction::SelectBedrockAuth(method) => {
self.draft_bedrock.auth_method = *method;
self.sync_bedrock_auth_buttons(ctx);
self.update_next_button(ctx);
ctx.notify();
}
ProviderSetupModalBodyAction::ToggleBedrockCrossRegion => {
ProviderSetupViewAction::ToggleBedrockCrossRegion => {
self.draft_bedrock.cross_region_inference =
!self.draft_bedrock.cross_region_inference;
ctx.notify();
}
ProviderSetupModalBodyAction::ToggleBedrockAutoLogin => {
ProviderSetupViewAction::ToggleBedrockAutoLogin => {
self.draft_bedrock.auto_login = !self.draft_bedrock.auto_login;
ctx.notify();
}
@@ -2045,7 +2238,8 @@ impl TypedActionView for ProviderSetupModalBody {
fn provider_type_label(kind: ProviderSetupProviderType) -> &'static str {
match kind {
ProviderSetupProviderType::OpenAICompatible => "OpenAI-compatible API",
ProviderSetupProviderType::OpenAI => "OpenAI",
ProviderSetupProviderType::LiteLLM => "LiteLLM",
ProviderSetupProviderType::ChatGPTSubscription => "ChatGPT subscription",
ProviderSetupProviderType::Anthropic => "Anthropic",
ProviderSetupProviderType::Gemini => "Google Gemini",
@@ -1,213 +0,0 @@
use warpui::elements::{
ChildView, Container, CrossAxisAlignment, DispatchEventResult, Element, EventHandler, Flex,
MainAxisAlignment, MainAxisSize, ParentElement, Text,
};
use warpui::{
AppContext, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
WeakViewHandle,
};
use crate::ai::llms::LLMId;
use crate::appearance::Appearance;
use crate::view_components::action_button::{ActionButton, NakedTheme, PrimaryTheme};
use crate::view_components::{DropdownItem, FilterableDropdown, FilterableDropdownEvent};
/// Width shared by the model dropdown's top bar and open menu so long model
/// names stay readable inside the modal.
const MODEL_DROPDOWN_WIDTH: f32 = 400.;
/// The body's `ui_font_size`-based default reads too small in the modal, so the
/// description uses an explicit, slightly larger size.
const DESCRIPTION_FONT_SIZE: f32 = 14.;
pub enum SetDefaultModelModalBodyEvent {
/// The user dismissed the prompt without choosing a model.
Close,
/// The user committed `LLMId` as their new default Agent Mode model.
SetDefault(LLMId),
}
#[derive(Debug, Clone, PartialEq)]
pub enum SetDefaultModelModalBodyAction {
/// Carries the index into `model_choices` of the picked model.
SelectModel(usize),
Save,
Cancel,
}
/// Body of the "change your default model" prompt that appears after a BYO API
/// key or custom endpoint is saved. It is hosted inside a [`crate::modal::Modal`],
/// which supplies the title, close button, and backdrop.
pub struct SetDefaultModelModalBody {
description: String,
/// `(model id, label)` pairs offered in the dropdown. The id flows back out
/// through [`SetDefaultModelModalBodyEvent::SetDefault`] on save.
model_choices: Vec<(LLMId, String)>,
selected_index: usize,
model_dropdown: ViewHandle<FilterableDropdown<SetDefaultModelModalBodyAction>>,
cancel_button: ViewHandle<ActionButton>,
save_button: ViewHandle<ActionButton>,
self_handle: WeakViewHandle<Self>,
}
impl SetDefaultModelModalBody {
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
let model_dropdown = ctx.add_typed_action_view(|ctx| {
let mut dropdown = FilterableDropdown::new(ctx);
dropdown.set_top_bar_max_width(MODEL_DROPDOWN_WIDTH);
dropdown.set_menu_width(MODEL_DROPDOWN_WIDTH, ctx);
dropdown
});
// When the dropdown closes (selection or dismiss), return focus to the
// body so Escape closes the modal rather than no-op'ing on the hidden
// filter input.
ctx.subscribe_to_view(&model_dropdown, |_, _, event, ctx| {
if let FilterableDropdownEvent::Close = event {
ctx.focus_self();
}
});
let cancel_button = ctx.add_typed_action_view(|_| {
ActionButton::new("Not now", NakedTheme).on_click(|ctx| {
ctx.dispatch_typed_action(SetDefaultModelModalBodyAction::Cancel);
})
});
let save_button = ctx.add_typed_action_view(|_| {
ActionButton::new("Change default model", PrimaryTheme).on_click(|ctx| {
ctx.dispatch_typed_action(SetDefaultModelModalBodyAction::Save);
})
});
Self {
description: String::new(),
model_choices: Vec::new(),
selected_index: 0,
model_dropdown,
cancel_button,
save_button,
self_handle: ctx.handle(),
}
}
/// Populates the prompt for a freshly added credential and focuses the body
/// so Escape closes the modal. The first model is pre-selected so the user
/// can accept without opening the dropdown.
pub fn set_choices(
&mut self,
description: String,
model_choices: Vec<(LLMId, String)>,
ctx: &mut ViewContext<Self>,
) {
self.description = description;
self.model_choices = model_choices;
self.selected_index = 0;
let items = self
.model_choices
.iter()
.enumerate()
.map(|(index, (_, label))| {
DropdownItem::new(
label.clone(),
SetDefaultModelModalBodyAction::SelectModel(index),
)
})
.collect();
self.model_dropdown.update(ctx, |dropdown, ctx| {
dropdown.set_items(items, ctx);
dropdown.set_selected_by_index(0, ctx);
});
ctx.focus_self();
ctx.notify();
}
}
impl Entity for SetDefaultModelModalBody {
type Event = SetDefaultModelModalBodyEvent;
}
impl View for SetDefaultModelModalBody {
fn ui_name() -> &'static str {
"SetDefaultModelModalBody"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let description = Container::new(
Text::new(
self.description.clone(),
appearance.ui_font_family(),
DESCRIPTION_FONT_SIZE,
)
.with_color(theme.nonactive_ui_text_color().into())
.soft_wrap(true)
.finish(),
)
.with_margin_bottom(20.)
.finish();
let dropdown = Container::new(ChildView::new(&self.model_dropdown).finish())
.with_margin_bottom(24.)
.finish();
let buttons_row = Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_main_axis_alignment(MainAxisAlignment::End)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(ChildView::new(&self.cancel_button).finish())
.with_child(
Container::new(ChildView::new(&self.save_button).finish())
.with_margin_left(12.)
.finish(),
)
.finish();
let content = Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_child(description)
.with_child(dropdown)
.with_child(buttons_row)
.finish();
// Close the modal on Escape when the body itself is focused. While the
// dropdown is open it owns Escape (to close itself); on close it hands
// focus back to the body via the `Close` subscription above.
let self_handle = self.self_handle.clone();
EventHandler::new(content)
.on_keydown(move |ctx, app, keystroke| {
let body_focused = self_handle
.upgrade(app)
.is_some_and(|handle| handle.is_focused(app));
if body_focused && keystroke.is_unmodified_key("escape") {
ctx.dispatch_typed_action(SetDefaultModelModalBodyAction::Cancel);
DispatchEventResult::StopPropagation
} else {
DispatchEventResult::PropagateToParent
}
})
.finish()
}
}
impl TypedActionView for SetDefaultModelModalBody {
type Action = SetDefaultModelModalBodyAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
SetDefaultModelModalBodyAction::SelectModel(index) => {
self.selected_index = *index;
ctx.notify();
}
SetDefaultModelModalBodyAction::Save => {
if let Some((id, _)) = self.model_choices.get(self.selected_index) {
ctx.emit(SetDefaultModelModalBodyEvent::SetDefault(id.clone()));
}
}
SetDefaultModelModalBodyAction::Cancel => {
ctx.emit(SetDefaultModelModalBodyEvent::Close);
}
}
}
}