Add OpenAI/LiteLLM provider support with settings UI
- Add openai/ provider module with translator, client, convert, request/response translators - Add shared provider/ types (ConversationMessage, MessageRole, ProviderConfig enum) - Wire OpenAI-compatible provider dispatch alongside Bedrock in response_stream.rs - Add ai.openai.* settings (enabled, base_url, api_key, model, models) - Add OpenAI/LiteLLM settings page with model fetch, picker, and config UI - Extend model menu items and llms.rs to surface LiteLLM models - Update WARP.md with OpenAI provider architecture docs
This commit is contained in:
@@ -26,7 +26,7 @@ use crate::settings::{
|
||||
AgentModeCommandExecutionPredicate, AgentModeQuerySuggestionsEnabled, BedrockAuthMethod,
|
||||
BedrockAutoLogin, BedrockEnabled, CodeSettings, CodebaseContextEnabled, FileBasedMcpEnabled,
|
||||
GitOperationsAutogenEnabled, IncludeAgentCommandsInHistory, IntelligentAutosuggestionsEnabled,
|
||||
MemoryEnabled, NLDInTerminalEnabled, NaturalLanguageAutosuggestionsEnabled,
|
||||
MemoryEnabled, NLDInTerminalEnabled, NaturalLanguageAutosuggestionsEnabled, OpenAIEnabled,
|
||||
RuleSuggestionsEnabled, SharedBlockTitleGenerationEnabled, ShouldRenderCLIAgentToolbar,
|
||||
ShouldRenderUseAgentToolbarForUserCommands, ShowAgentTips, ShowConversationHistory,
|
||||
ShowHintText, ThinkingDisplayMode, VoiceInputEnabled, WarpDriveContextEnabled,
|
||||
@@ -97,6 +97,8 @@ pub enum AISubpage {
|
||||
ThirdPartyCLIAgents,
|
||||
/// AWS Bedrock direct provider configuration.
|
||||
Bedrock,
|
||||
/// OpenAI-compatible (LiteLLM) provider configuration.
|
||||
OpenAI,
|
||||
}
|
||||
|
||||
impl AISubpage {
|
||||
@@ -107,6 +109,7 @@ impl AISubpage {
|
||||
SettingsSection::Knowledge => Some(Self::Knowledge),
|
||||
SettingsSection::ThirdPartyCLIAgents => Some(Self::ThirdPartyCLIAgents),
|
||||
SettingsSection::Bedrock => Some(Self::Bedrock),
|
||||
SettingsSection::OpenAI => Some(Self::OpenAI),
|
||||
// AgentMCPServers renders the standalone MCPServers page, not an AI subpage.
|
||||
_ => None,
|
||||
}
|
||||
@@ -1393,6 +1396,130 @@ impl AISettingsPageView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches models from the LiteLLM endpoint and updates settings.
|
||||
fn fetch_litellm_models(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
let settings = AISettings::as_ref(ctx);
|
||||
let base_url = settings.openai_base_url.value().clone();
|
||||
let api_key = {
|
||||
let key = settings.openai_api_key.value().clone();
|
||||
if key.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(key)
|
||||
}
|
||||
};
|
||||
|
||||
let _ = ctx.spawn(
|
||||
async move {
|
||||
use crate::settings::ai::OpenAIModelConfig;
|
||||
|
||||
let url = format!("{}/models", base_url.trim_end_matches('/'));
|
||||
let client = reqwest::Client::new();
|
||||
let mut request = client.get(&url);
|
||||
if let Some(ref key) = api_key {
|
||||
request = request.header("Authorization", format!("Bearer {key}"));
|
||||
}
|
||||
|
||||
let response = match request.send().await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
log::error!("[litellm] Failed to fetch models: {e}");
|
||||
return Vec::new();
|
||||
}
|
||||
};
|
||||
|
||||
if !response.status().is_success() {
|
||||
log::error!(
|
||||
"[litellm] Model fetch returned HTTP {}",
|
||||
response.status()
|
||||
);
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let body: serde_json::Value = match response.json().await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
log::error!("[litellm] Failed to parse models response: {e}");
|
||||
return Vec::new();
|
||||
}
|
||||
};
|
||||
|
||||
// LiteLLM /models endpoint returns OpenAI-compatible format:
|
||||
// { "data": [{ "id": "model-name", "max_model_len": N, ... }] }
|
||||
let models: Vec<OpenAIModelConfig> = body["data"]
|
||||
.as_array()
|
||||
.unwrap_or(&vec![])
|
||||
.iter()
|
||||
.filter_map(|m| {
|
||||
let id = m["id"].as_str()?;
|
||||
// Try multiple context window fields used by different proxies
|
||||
let context_size = m["max_model_len"]
|
||||
.as_u64()
|
||||
.or_else(|| m["context_window"].as_u64())
|
||||
.or_else(|| m["max_input_tokens"].as_u64())
|
||||
.unwrap_or(200_000) as u32;
|
||||
|
||||
// Derive display name from model ID
|
||||
let display_name = id
|
||||
.split('/')
|
||||
.last()
|
||||
.unwrap_or(id)
|
||||
.replace('-', " ")
|
||||
.replace('_', " ");
|
||||
// Capitalize first letter of each word
|
||||
let display_name = display_name
|
||||
.split_whitespace()
|
||||
.map(|word| {
|
||||
let mut chars = word.chars();
|
||||
match chars.next() {
|
||||
None => String::new(),
|
||||
Some(c) => {
|
||||
c.to_uppercase().to_string() + chars.as_str()
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
|
||||
// Infer provider from model ID prefix
|
||||
let provider = if id.contains("claude") || id.contains("anthropic") {
|
||||
Some("anthropic".to_string())
|
||||
} else if id.contains("gpt") || id.contains("o1") || id.contains("o3") {
|
||||
Some("openai".to_string())
|
||||
} else if id.contains("gemini") {
|
||||
Some("google".to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Some(OpenAIModelConfig {
|
||||
model_id: id.to_string(),
|
||||
display_name,
|
||||
vision_supported: m["supports_vision"].as_bool().unwrap_or(false),
|
||||
context_size,
|
||||
provider,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
log::info!(
|
||||
"[litellm] Fetched {} model(s) from {}",
|
||||
models.len(),
|
||||
url
|
||||
);
|
||||
models
|
||||
},
|
||||
|_view, models, ctx| {
|
||||
if !models.is_empty() {
|
||||
AISettings::handle(ctx).update(ctx, |settings, ctx| {
|
||||
let _ = settings.openai_models.set_value(models, ctx);
|
||||
});
|
||||
}
|
||||
ctx.notify();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn build_page(
|
||||
subpage: Option<AISubpage>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
@@ -1505,6 +1632,12 @@ impl AISettingsPageView {
|
||||
let title: Option<&str> = None;
|
||||
return (PageType::new_uncategorized(widgets, title), None);
|
||||
}
|
||||
Some(AISubpage::OpenAI) => {
|
||||
let widget = OpenAISettingsWidget::new(ctx);
|
||||
widgets.push(Box::new(widget));
|
||||
let title: Option<&str> = None;
|
||||
return (PageType::new_uncategorized(widgets, title), None);
|
||||
}
|
||||
}
|
||||
|
||||
// Subpage widgets render their own subheader-sized titles internally,
|
||||
@@ -2088,6 +2221,8 @@ pub enum AISettingsPageAction {
|
||||
SetBedrockAuthMethod(BedrockAuthMethod),
|
||||
SetBedrockProfile(String),
|
||||
ToggleBedrockCrossRegionInference,
|
||||
ToggleOpenAIEnabled,
|
||||
FetchOpenAIModels,
|
||||
ToggleFileBasedMcp,
|
||||
ToggleIncludeAgentCommandsInHistory,
|
||||
ToggleAgentAttribution,
|
||||
@@ -2756,6 +2891,16 @@ impl TypedActionView for AISettingsPageView {
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
AISettingsPageAction::ToggleOpenAIEnabled => {
|
||||
AISettings::handle(ctx).update(ctx, |settings, ctx| {
|
||||
report_if_error!(settings.openai_enabled.toggle_and_save_value(ctx));
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
AISettingsPageAction::FetchOpenAIModels => {
|
||||
// Trigger a fetch of models from the LiteLLM endpoint
|
||||
self.fetch_litellm_models(ctx);
|
||||
}
|
||||
AISettingsPageAction::ToggleFileBasedMcp => {
|
||||
AISettings::handle(ctx).update(ctx, |settings, ctx| {
|
||||
report_if_error!(settings.file_based_mcp_enabled.toggle_and_save_value(ctx));
|
||||
@@ -3731,7 +3876,6 @@ impl SettingsWidget for ActiveAIWidget {
|
||||
.finish(),
|
||||
);
|
||||
|
||||
|
||||
if self.is_next_command_toggleable(app) {
|
||||
column.add_child(self.render_next_command_section(view, app));
|
||||
}
|
||||
@@ -6231,7 +6375,11 @@ impl SettingsWidget for BedrockSettingsWidget {
|
||||
let description = format!(
|
||||
"{} model{} configured via settings.toml.",
|
||||
configured_models.len(),
|
||||
if configured_models.len() == 1 { "" } else { "s" }
|
||||
if configured_models.len() == 1 {
|
||||
""
|
||||
} else {
|
||||
"s"
|
||||
}
|
||||
);
|
||||
column.add_child(render_ai_setting_description(description, is_enabled, app));
|
||||
} else {
|
||||
@@ -6246,6 +6394,274 @@ impl SettingsWidget for BedrockSettingsWidget {
|
||||
}
|
||||
}
|
||||
|
||||
struct OpenAISettingsWidget {
|
||||
enabled_toggle: SwitchStateHandle,
|
||||
base_url_editor: ViewHandle<EditorView>,
|
||||
api_key_editor: ViewHandle<EditorView>,
|
||||
fetch_button: MouseStateHandle,
|
||||
}
|
||||
|
||||
impl OpenAISettingsWidget {
|
||||
fn new(ctx: &mut ViewContext<<Self as SettingsWidget>::View>) -> Self {
|
||||
let ai_settings = AISettings::as_ref(ctx);
|
||||
|
||||
let base_url_val = ai_settings.openai_base_url.value().clone();
|
||||
let api_key_val = ai_settings.openai_api_key.value().clone();
|
||||
|
||||
let base_url_editor = ctx.add_typed_action_view(move |ctx| {
|
||||
let appearance = Appearance::as_ref(ctx);
|
||||
let options = SingleLineEditorOptions {
|
||||
is_password: false,
|
||||
text: TextOptions {
|
||||
font_size_override: Some(appearance.ui_font_size()),
|
||||
font_family_override: Some(appearance.monospace_font_family()),
|
||||
text_colors_override: Some(TextColors {
|
||||
default_color: appearance.theme().active_ui_text_color(),
|
||||
disabled_color: appearance.theme().disabled_ui_text_color(),
|
||||
hint_color: appearance.theme().disabled_ui_text_color(),
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
let mut editor = EditorView::single_line(options, ctx);
|
||||
editor.set_placeholder_text("http://localhost:4000/v1", ctx);
|
||||
editor.set_buffer_text(&base_url_val, ctx);
|
||||
editor
|
||||
});
|
||||
ctx.subscribe_to_view(&base_url_editor, |_, editor, event, ctx| {
|
||||
if matches!(event, EditorEvent::Blurred | EditorEvent::Enter) {
|
||||
let value = editor.as_ref(ctx).buffer_text(ctx);
|
||||
AISettings::handle(ctx).update(ctx, |settings, ctx| {
|
||||
let _ = settings.openai_base_url.set_value(value, ctx);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
let api_key_editor = ctx.add_typed_action_view(move |ctx| {
|
||||
let appearance = Appearance::as_ref(ctx);
|
||||
let options = SingleLineEditorOptions {
|
||||
is_password: true,
|
||||
text: TextOptions {
|
||||
font_size_override: Some(appearance.ui_font_size()),
|
||||
font_family_override: Some(appearance.monospace_font_family()),
|
||||
text_colors_override: Some(TextColors {
|
||||
default_color: appearance.theme().active_ui_text_color(),
|
||||
disabled_color: appearance.theme().disabled_ui_text_color(),
|
||||
hint_color: appearance.theme().disabled_ui_text_color(),
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
let mut editor = EditorView::single_line(options, ctx);
|
||||
editor.set_placeholder_text("sk-... (optional)", ctx);
|
||||
editor.set_buffer_text(&api_key_val, ctx);
|
||||
editor
|
||||
});
|
||||
ctx.subscribe_to_view(&api_key_editor, |_, editor, event, ctx| {
|
||||
if matches!(event, EditorEvent::Blurred | EditorEvent::Enter) {
|
||||
let value = editor.as_ref(ctx).buffer_text(ctx);
|
||||
AISettings::handle(ctx).update(ctx, |settings, ctx| {
|
||||
let _ = settings.openai_api_key.set_value(value, ctx);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
let base_url_editor_clone = base_url_editor.clone();
|
||||
let api_key_editor_clone = api_key_editor.clone();
|
||||
ctx.subscribe_to_model(&AISettings::handle(ctx), move |_, _, event, ctx| {
|
||||
if matches!(event, AISettingsChangedEvent::OpenAIEnabled { .. }) {
|
||||
let is_enabled = *AISettings::as_ref(ctx).openai_enabled.value();
|
||||
AISettingsPageView::update_editor_interaction_state(
|
||||
base_url_editor_clone.clone(),
|
||||
is_enabled,
|
||||
ctx,
|
||||
);
|
||||
AISettingsPageView::update_editor_interaction_state(
|
||||
api_key_editor_clone.clone(),
|
||||
is_enabled,
|
||||
ctx,
|
||||
);
|
||||
ctx.notify();
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
enabled_toggle: SwitchStateHandle::default(),
|
||||
base_url_editor,
|
||||
api_key_editor,
|
||||
fetch_button: MouseStateHandle::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_input(
|
||||
appearance: &Appearance,
|
||||
label: &'static str,
|
||||
editor: ViewHandle<EditorView>,
|
||||
is_enabled: bool,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let padding = Some(Coords {
|
||||
top: 10.,
|
||||
bottom: 10.,
|
||||
left: 16.,
|
||||
right: 16.,
|
||||
});
|
||||
let editor_style = UiComponentStyles {
|
||||
padding,
|
||||
background: Some(appearance.theme().surface_2().into()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let label = Text::new_inline(label, appearance.ui_font_family(), CONTENT_FONT_SIZE)
|
||||
.with_color(styles::header_font_color(is_enabled, app).into())
|
||||
.finish();
|
||||
|
||||
let input = appearance
|
||||
.ui_builder()
|
||||
.text_input(editor)
|
||||
.with_style(editor_style)
|
||||
.build()
|
||||
.finish();
|
||||
|
||||
Flex::column()
|
||||
.with_spacing(8.)
|
||||
.with_child(label)
|
||||
.with_child(input)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl SettingsWidget for OpenAISettingsWidget {
|
||||
type View = AISettingsPageView;
|
||||
|
||||
fn search_terms(&self) -> &str {
|
||||
"openai litellm custom provider endpoint api key models"
|
||||
}
|
||||
|
||||
fn should_render(&self, _app: &AppContext) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn render(
|
||||
&self,
|
||||
_view: &Self::View,
|
||||
appearance: &Appearance,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let ai_settings = AISettings::as_ref(app);
|
||||
let is_enabled = *ai_settings.openai_enabled.value();
|
||||
|
||||
let mut column = Flex::column().with_spacing(16.);
|
||||
|
||||
column.add_child(render_ai_setting_toggle::<OpenAIEnabled>(
|
||||
"Enable OpenAI-Compatible Provider",
|
||||
AISettingsPageAction::ToggleOpenAIEnabled,
|
||||
is_enabled,
|
||||
true,
|
||||
self.enabled_toggle.clone(),
|
||||
&RefCell::new(HashMap::new()),
|
||||
app,
|
||||
));
|
||||
column.add_child(render_ai_setting_description(
|
||||
"Route AI requests through an OpenAI-compatible endpoint (e.g. LiteLLM proxy).",
|
||||
true,
|
||||
app,
|
||||
));
|
||||
|
||||
column.add_child(render_separator(appearance));
|
||||
|
||||
column.add_child(Self::render_input(
|
||||
appearance,
|
||||
"Base URL",
|
||||
self.base_url_editor.clone(),
|
||||
is_enabled,
|
||||
app,
|
||||
));
|
||||
column.add_child(render_ai_setting_description(
|
||||
"The OpenAI-compatible API base URL (e.g. http://localhost:4000/v1).",
|
||||
is_enabled,
|
||||
app,
|
||||
));
|
||||
|
||||
column.add_child(Self::render_input(
|
||||
appearance,
|
||||
"API Key",
|
||||
self.api_key_editor.clone(),
|
||||
is_enabled,
|
||||
app,
|
||||
));
|
||||
column.add_child(render_ai_setting_description(
|
||||
"Optional. Leave empty if the proxy handles authentication.",
|
||||
is_enabled,
|
||||
app,
|
||||
));
|
||||
|
||||
column.add_child(render_separator(appearance));
|
||||
|
||||
// Fetch models button
|
||||
let fetch_button = appearance
|
||||
.ui_builder()
|
||||
.button(ButtonVariant::Secondary, self.fetch_button.clone())
|
||||
.with_text_label("Fetch Models from Endpoint".to_owned())
|
||||
.build()
|
||||
.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(AISettingsPageAction::FetchOpenAIModels);
|
||||
})
|
||||
.finish();
|
||||
column.add_child(fetch_button);
|
||||
column.add_child(render_ai_setting_description(
|
||||
"Queries the /models endpoint and populates the model list with available models and their context window sizes.",
|
||||
is_enabled,
|
||||
app,
|
||||
));
|
||||
|
||||
column.add_child(render_separator(appearance));
|
||||
|
||||
// Show configured models count
|
||||
let configured_models: Vec<_> = ai_settings.openai_models.value().clone();
|
||||
if !configured_models.is_empty() {
|
||||
let description = format!(
|
||||
"{} model{} configured via settings.toml.",
|
||||
configured_models.len(),
|
||||
if configured_models.len() == 1 {
|
||||
""
|
||||
} else {
|
||||
"s"
|
||||
}
|
||||
);
|
||||
column.add_child(render_ai_setting_description(description, is_enabled, app));
|
||||
|
||||
// Show first few model names
|
||||
let preview: String = configured_models
|
||||
.iter()
|
||||
.take(5)
|
||||
.map(|m| m.display_name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let suffix = if configured_models.len() > 5 {
|
||||
format!(" (+{} more)", configured_models.len() - 5)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
column.add_child(render_ai_setting_description(
|
||||
format!("Models: {preview}{suffix}"),
|
||||
is_enabled,
|
||||
app,
|
||||
));
|
||||
} else {
|
||||
column.add_child(render_ai_setting_description(
|
||||
"No models configured. Use 'Fetch Models' or add them to ~/.galaxy/settings.toml under [ai.openai].",
|
||||
is_enabled,
|
||||
app,
|
||||
));
|
||||
}
|
||||
|
||||
column.finish()
|
||||
}
|
||||
}
|
||||
|
||||
mod styles {
|
||||
use galaxy_core::ui::{appearance::Appearance, theme::Fill};
|
||||
use galaxyui::{AppContext, SingletonEntity};
|
||||
|
||||
@@ -202,6 +202,7 @@ pub enum SettingsSection {
|
||||
Knowledge,
|
||||
ThirdPartyCLIAgents,
|
||||
Bedrock,
|
||||
OpenAI,
|
||||
/// 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`.
|
||||
@@ -239,6 +240,7 @@ impl Display for SettingsSection {
|
||||
SettingsSection::Knowledge => write!(f, "Knowledge"),
|
||||
SettingsSection::ThirdPartyCLIAgents => write!(f, "Third party CLI agents"),
|
||||
SettingsSection::Bedrock => write!(f, "AWS Bedrock"),
|
||||
SettingsSection::OpenAI => write!(f, "OpenAI / LiteLLM"),
|
||||
SettingsSection::Warpify => write!(f, "Wormhole"),
|
||||
SettingsSection::CodeIndexing => write!(f, "Indexing and projects"),
|
||||
SettingsSection::EditorAndCodeReview => write!(f, "Editor and Code Review"),
|
||||
@@ -263,6 +265,7 @@ impl SettingsSection {
|
||||
| Self::Knowledge
|
||||
| Self::ThirdPartyCLIAgents
|
||||
| Self::Bedrock
|
||||
| Self::OpenAI
|
||||
)
|
||||
}
|
||||
|
||||
@@ -294,6 +297,7 @@ impl SettingsSection {
|
||||
Self::Knowledge,
|
||||
Self::ThirdPartyCLIAgents,
|
||||
Self::Bedrock,
|
||||
Self::OpenAI,
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user