Improve AI provider model configuration

This commit is contained in:
2026-08-09 15:48:15 -05:00
parent 603437a24e
commit 170a87e981
13 changed files with 748 additions and 142 deletions
+9 -4
View File
@@ -1,4 +1,7 @@
use galaxy_acp::{AcpAgentPreset, AcpLaunchConfig, CODEX_ACP_NPM_VERSION, OPENCODE_NPM_VERSION};
use galaxy_acp::{
resolve_known_acp_agent, AcpAgentPreset, AcpLaunchConfig, CODEX_ACP_NPM_VERSION,
OPENCODE_NPM_VERSION,
};
use sha2::{Digest as _, Sha256};
use crate::persistence::model::AcpConversationData;
@@ -215,9 +218,11 @@ pub(crate) fn resolve_acp_launch(
match agent_id.trim().to_ascii_lowercase().as_str() {
"codex" => AcpAgentPreset::Codex.resolve_launch_config(),
"opencode" => AcpAgentPreset::OpenCode.resolve_launch_config(),
unknown => Err(format!(
"Unknown ACP agent preset {unknown:?}; choose \"codex\" or \"opencode\", or configure a custom ACP executable"
)),
_ => resolve_known_acp_agent(agent_id).map_err(|error| {
format!(
"{error} Configure a custom ACP executable if this client uses a different command."
)
}),
}
}
+1 -1
View File
@@ -4,7 +4,7 @@ use super::*;
fn unknown_builtin_agent_ids_are_rejected() {
let error = resolve_acp_launch("mystery-agent", "", &[]).unwrap_err();
assert!(error.contains("Unknown ACP agent preset"));
assert!(error.contains("Unknown ACP agent"));
}
#[test]
+8 -5
View File
@@ -509,7 +509,7 @@ fn default_computer_use_llms() -> AvailableLLMs {
},
description: None,
disable_reason: None,
vision_supported: true,
vision_supported: false,
spec: None,
provider: LLMProvider::Unknown,
host_configs: HashMap::new(),
@@ -1164,7 +1164,7 @@ impl LLMPreferences {
},
description: Some(provider_name.clone()),
disable_reason: None,
vision_supported: model.vision_supported,
vision_supported: model.effective_vision_supported(),
spec: None,
provider: LLMProvider::LiteLLM,
host_configs: HashMap::from([(
@@ -1670,13 +1670,14 @@ impl LLMPreferences {
.map(|model| OpenAIModelConfig {
model_id: model.id,
display_name: model.display_name,
vision_supported: false,
vision_supported: true,
context_size: model.context_size.unwrap_or(128_000),
max_input_tokens: model.context_size,
max_output_tokens: None,
provider: None,
use_rig: true,
supports_system_messages: Some(true),
capability_overrides: std::collections::HashMap::new(),
reasoning_efforts: Vec::new(),
enabled: true,
})
@@ -2594,7 +2595,7 @@ async fn fetch_from_litellm_model_info(
.and_then(|v| u32::try_from(v).ok());
let context_size = max_input_tokens.unwrap_or(200_000);
let vision_supported = model_info["supports_vision"].as_bool().unwrap_or(false);
let vision_supported = model_info["supports_vision"].as_bool().unwrap_or(true);
let display_name = model_name.replace(['-', '_'], " ");
let display_name = display_name
@@ -2651,6 +2652,7 @@ async fn fetch_from_litellm_model_info(
} else {
model_info["supports_system_messages"].as_bool()
},
capability_overrides: std::collections::HashMap::new(),
reasoning_efforts: Vec::new(),
enabled: true,
})
@@ -2769,7 +2771,7 @@ async fn fetch_from_openai_models(
vision_supported: m["supports_vision"]
.as_bool()
.or_else(|| m["vision_support"].as_bool())
.unwrap_or(false),
.unwrap_or(true),
context_size,
max_input_tokens,
max_output_tokens,
@@ -2780,6 +2782,7 @@ async fn fetch_from_openai_models(
} else {
m["supports_system_messages"].as_bool()
},
capability_overrides: std::collections::HashMap::new(),
reasoning_efforts: Vec::new(),
enabled: true,
})
+1
View File
@@ -152,6 +152,7 @@ fn openai_model(model_id: &str) -> OpenAIModelConfig {
provider: None,
use_rig: false,
supports_system_messages: None,
capability_overrides: std::collections::HashMap::new(),
reasoning_efforts: Vec::new(),
enabled: true,
}
+1 -1
View File
@@ -1656,7 +1656,7 @@ impl Element for EditorElement {
);
}
if size.x().is_infinite() {
unimplemented!("we don't yet handle an infinite width constraint on buffer elements");
size.set_x(0.0);
}
let top_section_height_lines = top_section_height_px / view_snapshot.line_height;
+69 -3
View File
@@ -893,6 +893,13 @@ pub struct OpenAIModelConfig {
description = "Whether this endpoint accepts system-role messages. Set false for ChatGPT-backed LiteLLM models that reject them."
)]
pub supports_system_messages: Option<bool>,
/// Per-model capability overrides. Missing entries mean Auto: use provider
/// metadata when available and allow the request path to determine support.
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
#[schemars(
description = "Optional per-capability overrides: auto, supported, or unsupported."
)]
pub capability_overrides: HashMap<String, ModelCapabilityOverride>,
#[serde(default)]
#[schemars(
description = "Reasoning effort modes supported by this model when using the ChatGPT subscription provider."
@@ -906,11 +913,62 @@ pub struct OpenAIModelConfig {
impl settings_value::SettingsValue for OpenAIModelConfig {}
impl OpenAIModelConfig {
pub fn capability_override(&self, capability: &str) -> ModelCapabilityOverride {
self.capability_overrides
.get(capability)
.copied()
.unwrap_or_default()
}
pub fn effective_vision_supported(&self) -> bool {
match self.capability_override("vision") {
ModelCapabilityOverride::Auto => self.vision_supported,
ModelCapabilityOverride::Supported => true,
ModelCapabilityOverride::Unsupported => false,
}
}
pub fn supports_system_messages(&self) -> bool {
if self.capability_override("system_messages") == ModelCapabilityOverride::Unsupported {
return false;
}
if self.model_id.starts_with("codex-gpt-") {
return false;
}
self.supports_system_messages.unwrap_or(true)
match self.capability_override("system_messages") {
ModelCapabilityOverride::Supported => true,
ModelCapabilityOverride::Auto => self.supports_system_messages.unwrap_or(true),
ModelCapabilityOverride::Unsupported => false,
}
}
}
#[derive(
Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, schemars::JsonSchema,
)]
#[serde(rename_all = "snake_case")]
pub enum ModelCapabilityOverride {
#[default]
Auto,
Supported,
Unsupported,
}
impl ModelCapabilityOverride {
pub fn next(self) -> Self {
match self {
Self::Auto => Self::Supported,
Self::Supported => Self::Unsupported,
Self::Unsupported => Self::Auto,
}
}
pub fn label(self) -> &'static str {
match self {
Self::Auto => "Auto",
Self::Supported => "On",
Self::Unsupported => "Off",
}
}
}
@@ -1013,13 +1071,18 @@ fn default_chatgpt_models() -> Vec<OpenAIModelConfig> {
|(model_id, display_name, reasoning_efforts)| OpenAIModelConfig {
model_id: model_id.to_string(),
display_name: display_name.to_string(),
vision_supported: false,
// ChatGPT's subscription backend accepts image input for its chat
// models, but it does not expose a public capability discovery
// endpoint. Keep this explicit catalog in sync with that contract
// so the model picker does not hide vision context.
vision_supported: true,
context_size: default_context_size(),
max_input_tokens: None,
max_output_tokens: None,
provider: Some("openai".to_string()),
use_rig: true,
supports_system_messages: Some(true),
capability_overrides: HashMap::new(),
reasoning_efforts: reasoning_efforts.into_iter().map(str::to_string).collect(),
enabled: true,
},
@@ -1055,13 +1118,16 @@ fn default_openai_providers() -> Vec<OpenAIProviderConfig> {
models: vec![OpenAIModelConfig {
model_id: INITIAL_RIG_MODEL_ID.to_string(),
display_name: "Codex GPT-5.6 SOL (xhigh)".to_string(),
vision_supported: false,
// Auto capability detection is optimistic for modern
// multimodal-compatible endpoints; users can override it per model.
vision_supported: true,
context_size: default_context_size(),
max_input_tokens: None,
max_output_tokens: None,
provider: Some("openai".to_string()),
use_rig: true,
supports_system_messages: Some(false),
capability_overrides: HashMap::new(),
reasoning_efforts: Vec::new(),
enabled: true,
}],
+27 -10
View File
@@ -707,13 +707,19 @@ impl AISettingsPageView {
draft: &AcpProviderDraft,
ctx: &mut ViewContext<Self>,
) {
let Ok(config) = crate::ai::acp::AcpRuntimeModel::discovery_config_for_values(
let config = match crate::ai::acp::AcpRuntimeModel::discovery_config_for_values(
&draft.agent_id,
&draft.command,
&draft.args,
) else {
log::warn!("Could not resolve ACP launch configuration for discovery");
return;
) {
Ok(config) => config,
Err(error) => {
log::warn!("Could not resolve ACP launch configuration for discovery: {error}");
self.provider_setup_modal_body.update(ctx, |body, ctx| {
body.finish_acp_discovery(Err(error), Vec::new(), ctx);
});
return;
}
};
self.start_acp_discovery(config, draft.agent_id.clone(), ctx);
}
@@ -739,7 +745,7 @@ impl AISettingsPageView {
log::warn!("Could not start ACP discovery: {error}");
let error_text = error.to_string();
provider_setup_modal_body.update(ctx, |body, ctx| {
body.finish_acp_discovery(Err(error_text), ctx);
body.finish_acp_discovery(Err(error_text), Vec::new(), ctx);
});
return;
}
@@ -751,6 +757,10 @@ impl AISettingsPageView {
match result {
Ok(options) => {
let option_count = options.len();
let config_options =
crate::ai::acp::AcpRuntimeModel::normalize_config_options(
options.clone(),
);
AISettings::handle(ctx).update(ctx, |settings, ctx| {
if let Err(error) =
crate::ai::acp::AcpRuntimeModel::upsert_agent_settings(
@@ -774,7 +784,7 @@ impl AISettingsPageView {
runtime.finish_discovery_success(option_count, ctx);
});
provider_setup_modal_body.update(ctx, |body, ctx| {
body.finish_acp_discovery(Ok(()), ctx);
body.finish_acp_discovery(Ok(()), config_options, ctx);
});
}
Err(error) => {
@@ -796,7 +806,7 @@ impl AISettingsPageView {
runtime.finish_discovery_failure(error_text.clone(), ctx);
});
provider_setup_modal_body.update(ctx, |body, ctx| {
body.finish_acp_discovery(Err(error_text), ctx);
body.finish_acp_discovery(Err(error_text), Vec::new(), ctx);
});
}
}
@@ -1791,12 +1801,12 @@ impl AISettingsPageView {
ctx,
)
.with_modal_style(UiComponentStyles {
width: Some(640.),
height: Some(600.),
width: Some(900.),
height: Some(700.),
..Default::default()
})
.with_body_style(UiComponentStyles {
height: Some(530.),
height: Some(630.),
..Default::default()
})
.with_dismiss_on_click()
@@ -2017,6 +2027,13 @@ impl AISettingsPageView {
agent_id: settings.acp_agent_id.value().clone(),
command: settings.acp_agent_command.value().clone(),
args: settings.acp_agent_args.value().clone(),
config_options: settings
.acp_agents
.value()
.iter()
.find(|agent| agent.id.eq_ignore_ascii_case(settings.acp_agent_id.value()))
.map(|agent| agent.config_options.clone())
.unwrap_or_default(),
};
let body = self
.provider_setup_modal_state
+467 -82
View File
@@ -5,6 +5,7 @@ use galaxyui::elements::{
MouseStateHandle, Padding, ParentElement, Radius, ScrollbarWidth, Text,
};
use galaxyui::fonts::{Properties, Weight};
use galaxyui::text_layout::ClipConfig;
use galaxyui::ui_components::button::ButtonVariant;
use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
use galaxyui::ui_components::switch::SwitchStateHandle;
@@ -21,17 +22,17 @@ use crate::editor::{
};
use crate::modal::{Modal, ModalViewState};
use crate::settings::ai::{
BedrockAuthMethod, BedrockModelConfig, OpenAIModelConfig, OpenAIProviderConfig,
OpenAIProviderKind,
AcpConfigOptionSettings, BedrockAuthMethod, BedrockModelConfig, ModelCapabilityOverride,
OpenAIModelConfig, OpenAIProviderConfig, OpenAIProviderKind,
};
use crate::ui_components::icons::Icon;
use crate::view_components::action_button::{
ActionButton, NakedTheme, PrimaryTheme, SecondaryTheme,
ActionButton, ButtonSize, NakedTheme, PrimaryTheme, SecondaryTheme,
};
const MODAL_WIDTH: f32 = 640.;
const MODAL_HEIGHT: f32 = 600.;
const BODY_HEIGHT: f32 = 530.;
const MODAL_WIDTH: f32 = 900.;
const MODAL_HEIGHT: f32 = 700.;
const BODY_HEIGHT: f32 = 630.;
const INPUT_FONT_SIZE: f32 = 12.;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -111,6 +112,7 @@ pub struct AcpProviderDraft {
pub agent_id: String,
pub command: String,
pub args: Vec<String>,
pub config_options: Vec<AcpConfigOptionSettings>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -120,6 +122,45 @@ enum DiscoveryState {
Failed(String),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum CapabilityKey {
Vision,
Files,
Audio,
Tools,
SystemMessages,
}
impl CapabilityKey {
const ALL: [Self; 5] = [
Self::Vision,
Self::Files,
Self::Audio,
Self::Tools,
Self::SystemMessages,
];
fn label(self) -> &'static str {
match self {
Self::Vision => "Images",
Self::Files => "Files",
Self::Audio => "Audio",
Self::Tools => "Tools",
Self::SystemMessages => "System",
}
}
fn setting_key(self) -> &'static str {
match self {
Self::Vision => "vision",
Self::Files => "files",
Self::Audio => "audio",
Self::Tools => "tools",
Self::SystemMessages => "system_messages",
}
}
}
pub enum ProviderSetupModalBodyEvent {
Close,
RequestAcpDiscovery(AcpProviderDraft),
@@ -138,12 +179,14 @@ pub enum ProviderSetupModalBodyAction {
Back,
Cancel,
ToggleModel(usize),
CycleModelCapability(usize, CapabilityKey),
ConnectChatGPT,
OpenChatGPTDevicePage,
CopyChatGPTDeviceCode,
SelectBedrockAuth(BedrockAuthMethod),
ToggleBedrockCrossRegion,
ToggleBedrockAutoLogin,
SelectAcpAgent(String),
}
pub type ProviderSetupModalState = ModalViewState<Modal<ProviderSetupModalBody>>;
@@ -162,6 +205,7 @@ pub struct ProviderSetupModalBody {
draft_acp: AcpProviderDraft,
discovery_state: DiscoveryState,
provider_type_buttons: Vec<ViewHandle<ActionButton>>,
acp_agent_buttons: Vec<ViewHandle<ActionButton>>,
name_editor: ViewHandle<EditorView>,
base_url_editor: ViewHandle<EditorView>,
api_key_editor: ViewHandle<EditorView>,
@@ -175,10 +219,16 @@ pub struct ProviderSetupModalBody {
acp_agent_id_editor: ViewHandle<EditorView>,
acp_command_editor: ViewHandle<EditorView>,
acp_args_editor: ViewHandle<EditorView>,
chatgpt_connect_mouse_state: MouseStateHandle,
chatgpt_open_mouse_state: MouseStateHandle,
chatgpt_copy_mouse_state: MouseStateHandle,
bedrock_auth_buttons: Vec<ViewHandle<ActionButton>>,
bedrock_cross_region_toggle: SwitchStateHandle,
bedrock_auto_login_toggle: SwitchStateHandle,
model_switches: Vec<SwitchStateHandle>,
model_capability_switches: Vec<[SwitchStateHandle; 2]>,
model_capability_buttons: Vec<Vec<ViewHandle<ActionButton>>>,
model_context_editors: Vec<ViewHandle<EditorView>>,
provider_type_scroll_state: ClippedScrollStateHandle,
models_scroll_state: ClippedScrollStateHandle,
back_button: ViewHandle<ActionButton>,
@@ -219,6 +269,34 @@ impl ProviderSetupModalBody {
let acp_command_editor = Self::create_editor("Optional executable", false, ctx);
let acp_args_editor = Self::create_editor(r#"["arg1", "arg2"]"#, false, ctx);
let mut acp_agent_buttons = galaxy_acp::known_acp_agents()
.iter()
.map(|agent| {
let id = agent.id.to_owned();
ctx.add_typed_action_view(move |_| {
ActionButton::new(agent.name, NakedTheme)
.with_full_width(true)
.on_click({
let id = id.clone();
move |ctx| {
ctx.dispatch_typed_action(
ProviderSetupModalBodyAction::SelectAcpAgent(id.clone()),
);
}
})
})
})
.collect::<Vec<_>>();
acp_agent_buttons.push(ctx.add_typed_action_view(|_| {
ActionButton::new("Custom", NakedTheme)
.with_full_width(true)
.on_click(|ctx| {
ctx.dispatch_typed_action(ProviderSetupModalBodyAction::SelectAcpAgent(
"custom".to_owned(),
));
})
}));
let bedrock_auth_buttons = [
BedrockAuthMethod::Profile,
BedrockAuthMethod::Sso,
@@ -358,9 +436,11 @@ impl ProviderSetupModalBody {
agent_id: "codex".to_string(),
command: String::new(),
args: Vec::new(),
config_options: Vec::new(),
},
discovery_state: DiscoveryState::Idle,
provider_type_buttons,
acp_agent_buttons,
name_editor,
base_url_editor,
api_key_editor,
@@ -374,10 +454,16 @@ impl ProviderSetupModalBody {
acp_agent_id_editor,
acp_command_editor,
acp_args_editor,
chatgpt_connect_mouse_state: MouseStateHandle::default(),
chatgpt_open_mouse_state: MouseStateHandle::default(),
chatgpt_copy_mouse_state: MouseStateHandle::default(),
bedrock_auth_buttons,
bedrock_cross_region_toggle: SwitchStateHandle::default(),
bedrock_auto_login_toggle: SwitchStateHandle::default(),
model_switches: Vec::new(),
model_capability_switches: Vec::new(),
model_capability_buttons: Vec::new(),
model_context_editors: Vec::new(),
provider_type_scroll_state: ClippedScrollStateHandle::default(),
models_scroll_state: ClippedScrollStateHandle::default(),
back_button,
@@ -440,10 +526,12 @@ impl ProviderSetupModalBody {
agent_id: "codex".to_string(),
command: String::new(),
args: Vec::new(),
config_options: Vec::new(),
};
self.discovery_state = DiscoveryState::Idle;
self.sync_editors(ctx);
self.sync_provider_type_buttons(ctx);
self.sync_acp_agent_buttons(ctx);
self.sync_bedrock_auth_buttons(ctx);
self.sync_model_switches(ctx);
self.update_next_button(ctx);
@@ -457,7 +545,9 @@ impl ProviderSetupModalBody {
provider: OpenAIProviderConfig,
ctx: &mut ViewContext<Self>,
) {
self.step = ProviderSetupStep::Configure;
// Editing an existing provider is a local catalog operation. Do not
// send the user through credentials or model discovery again.
self.step = ProviderSetupStep::Models;
self.editing_index = Some(editing_index);
self.provider_type = match provider.kind {
OpenAIProviderKind::ChatGPTSubscription => {
@@ -477,6 +567,7 @@ impl ProviderSetupModalBody {
self.discovery_state = DiscoveryState::Idle;
self.sync_editors(ctx);
self.sync_provider_type_buttons(ctx);
self.sync_acp_agent_buttons(ctx);
self.sync_bedrock_auth_buttons(ctx);
self.sync_model_switches(ctx);
self.update_next_button(ctx);
@@ -502,7 +593,11 @@ impl ProviderSetupModalBody {
}
pub fn begin_edit_acp(&mut self, draft: AcpProviderDraft, ctx: &mut ViewContext<Self>) {
self.step = ProviderSetupStep::Configure;
self.step = if draft.config_options.is_empty() {
ProviderSetupStep::Configure
} else {
ProviderSetupStep::Models
};
self.editing_index = None;
self.provider_type = ProviderSetupProviderType::Acp;
self.draft_name = draft.name.clone();
@@ -521,10 +616,12 @@ impl ProviderSetupModalBody {
pub fn finish_acp_discovery(
&mut self,
result: Result<(), String>,
config_options: Vec<AcpConfigOptionSettings>,
ctx: &mut ViewContext<Self>,
) {
match result {
Ok(()) => {
self.draft_acp.config_options = config_options;
self.discovery_state = DiscoveryState::Idle;
self.step = ProviderSetupStep::Models;
ctx.focus(&self.name_editor);
@@ -593,6 +690,23 @@ impl ProviderSetupModalBody {
}
}
fn sync_acp_agent_buttons(&self, ctx: &mut ViewContext<Self>) {
let selected = self.draft_acp.agent_id.trim();
for (agent, button) in galaxy_acp::known_acp_agents()
.iter()
.zip(self.acp_agent_buttons.iter())
{
button.update(ctx, |button, ctx| {
button.set_active(agent.id.eq_ignore_ascii_case(selected), ctx);
});
}
if let Some(button) = self.acp_agent_buttons.last() {
button.update(ctx, |button, ctx| {
button.set_active(selected.eq_ignore_ascii_case("custom"), ctx);
});
}
}
fn sync_bedrock_auth_buttons(&self, ctx: &mut ViewContext<Self>) {
for (index, button) in self.bedrock_auth_buttons.iter().enumerate() {
let method = match index {
@@ -612,6 +726,68 @@ impl ProviderSetupModalBody {
self.model_switches.push(SwitchStateHandle::default());
}
self.model_switches.truncate(self.draft_models.len());
while self.model_capability_switches.len() < self.draft_models.len() {
self.model_capability_switches
.push([SwitchStateHandle::default(), SwitchStateHandle::default()]);
}
self.model_capability_switches
.truncate(self.draft_models.len());
while self.model_capability_buttons.len() < self.draft_models.len() {
let index = self.model_capability_buttons.len();
let buttons = CapabilityKey::ALL
.into_iter()
.map(|key| {
ctx.add_typed_action_view(move |_| {
ActionButton::new(format!("{}: Auto", key.label()), NakedTheme)
.with_size(ButtonSize::XSmall)
.on_click(move |ctx| {
ctx.dispatch_typed_action(
ProviderSetupModalBodyAction::CycleModelCapability(index, key),
);
})
})
})
.collect();
self.model_capability_buttons.push(buttons);
}
self.model_capability_buttons
.truncate(self.draft_models.len());
while self.model_context_editors.len() < self.draft_models.len() {
let index = self.model_context_editors.len();
let editor = Self::create_editor("Context window", false, ctx);
ctx.subscribe_to_view(&editor, move |me, editor, event, ctx| {
if matches!(event, EditorEvent::Edited(_)) {
if let Some(model) = me.draft_models.get_mut(index) {
if let Ok(context_size) = editor.as_ref(ctx).buffer_text(ctx).parse() {
model.context_size = context_size;
model.max_input_tokens = Some(context_size);
}
}
me.update_next_button(ctx);
ctx.notify();
}
});
self.model_context_editors.push(editor);
}
self.model_context_editors.truncate(self.draft_models.len());
for (index, model) in self.draft_models.iter().enumerate() {
for (button, key) in self.model_capability_buttons[index]
.iter()
.zip(CapabilityKey::ALL)
{
let state = model.capability_override(key.setting_key());
button.update(ctx, |button, ctx| {
button.set_label(format!("{}: {}", key.label(), state.label()), ctx);
});
}
self.model_context_editors[index].update(ctx, |editor, ctx| {
editor.system_reset_buffer_text(&model.context_size.to_string(), ctx);
});
}
}
fn update_next_button(&self, ctx: &mut ViewContext<Self>) {
@@ -628,7 +804,11 @@ impl ProviderSetupModalBody {
.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(),
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,
};
@@ -957,6 +1137,14 @@ impl ProviderSetupModalBody {
.soft_wrap(true)
.finish(),
);
if let ChatGPTAuthState::Failed(error) = &state {
children.push(
Text::new(error.clone(), appearance.monospace_font_family(), 11.)
.with_color(appearance.theme().ui_error_color().into())
.soft_wrap(true)
.finish(),
);
}
if let ChatGPTAuthState::AwaitingDeviceCode {
verification_uri,
@@ -985,7 +1173,10 @@ impl ProviderSetupModalBody {
.with_child(
appearance
.ui_builder()
.button(ButtonVariant::Secondary, MouseStateHandle::default())
.button(
ButtonVariant::Secondary,
self.chatgpt_open_mouse_state.clone(),
)
.with_text_label("Open sign-in page".to_owned())
.build()
.on_click(|ctx, _, _| {
@@ -998,7 +1189,10 @@ impl ProviderSetupModalBody {
.with_child(
appearance
.ui_builder()
.button(ButtonVariant::Secondary, MouseStateHandle::default())
.button(
ButtonVariant::Secondary,
self.chatgpt_copy_mouse_state.clone(),
)
.with_text_label("Copy code".to_owned())
.build()
.on_click(|ctx, _, _| {
@@ -1029,7 +1223,10 @@ impl ProviderSetupModalBody {
children.push(
appearance
.ui_builder()
.button(ButtonVariant::Secondary, MouseStateHandle::default())
.button(
ButtonVariant::Secondary,
self.chatgpt_connect_mouse_state.clone(),
)
.with_text_label("Connect ChatGPT".to_owned())
.build()
.on_click(|ctx, _, _| {
@@ -1216,24 +1413,32 @@ impl ProviderSetupModalBody {
);
}
ProviderSetupProviderType::Acp => {
children.push(self.render_input(
appearance,
"Agent preset",
&self.acp_agent_id_editor,
));
children.push(self.render_input(
appearance,
"Custom executable (optional)",
&self.acp_command_editor,
));
children.push(self.render_input(
appearance,
"Arguments (JSON array)",
&self.acp_args_editor,
));
children.push(
Text::new("ACP client", appearance.ui_font_family(), INPUT_FONT_SIZE)
.with_color(appearance.theme().active_ui_text_color().into())
.with_style(Properties::default().weight(Weight::Bold))
.finish(),
);
children.extend(
self.acp_agent_buttons
.iter()
.map(|button| ChildView::new(button).finish()),
);
if self.draft_acp.agent_id.eq_ignore_ascii_case("custom") {
children.push(self.render_input(
appearance,
"Executable",
&self.acp_command_editor,
));
children.push(self.render_input(
appearance,
"Arguments (JSON array)",
&self.acp_args_editor,
));
}
children.push(
Text::new(
"ACP agents own their model and authentication. Galaxy will discover the configured runtime before saving.",
"Known clients use their local executable. If the client is not installed, Galaxy will show a launch error. Choose Custom for another ACP-compatible command.",
appearance.ui_font_family(),
INPUT_FONT_SIZE,
)
@@ -1330,7 +1535,150 @@ impl ProviderSetupModalBody {
.finish(),
)
.with_width(MODAL_WIDTH - 56.)
.with_max_height(320.)
.with_max_height(430.)
.finish()
}
fn render_model_table_header(&self, appearance: &Appearance) -> Box<dyn galaxyui::Element> {
let header = |label: &str| {
Text::new(label.to_owned(), appearance.ui_font_family(), 11.)
.with_color(appearance.theme().nonactive_ui_text_color().into())
.with_style(Properties::default().weight(Weight::Semibold))
.finish()
};
Container::new(
Flex::row()
.with_spacing(12.)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(ConstrainedBox::new(header("Use")).with_width(52.).finish())
.with_child(
ConstrainedBox::new(header("Model"))
.with_width(250.)
.finish(),
)
.with_child(
ConstrainedBox::new(header("Context"))
.with_width(140.)
.finish(),
)
.with_child(
ConstrainedBox::new(header("Capabilities"))
.with_width(330.)
.finish(),
)
.finish(),
)
.with_padding(Padding::uniform(10.).with_vertical(9.))
.with_background(appearance.theme().surface_2())
.with_border(Border::bottom(1.).with_border_fill(appearance.theme().outline()))
.finish()
}
fn render_model_capabilities(
&self,
appearance: &Appearance,
index: usize,
) -> Box<dyn galaxyui::Element> {
let buttons = &self.model_capability_buttons[index];
let first_row = buttons[..3]
.iter()
.map(|button| ChildView::new(button).finish())
.collect::<Vec<_>>();
let second_row = buttons[3..]
.iter()
.map(|button| ChildView::new(button).finish())
.collect::<Vec<_>>();
Flex::column()
.with_spacing(6.)
.with_child(
Flex::row()
.with_spacing(8.)
.with_children(first_row)
.finish(),
)
.with_child(
Flex::row()
.with_spacing(8.)
.with_children(second_row)
.finish(),
)
.finish()
}
fn render_openai_model_row(
&self,
appearance: &Appearance,
index: usize,
model: &OpenAIModelConfig,
) -> Box<dyn galaxyui::Element> {
let model_info = Flex::column()
.with_spacing(4.)
.with_child(
Text::new_inline(model.display_name.clone(), appearance.ui_font_family(), 12.)
.with_color(appearance.theme().active_ui_text_color().into())
.with_clip(ClipConfig::end())
.finish(),
)
.with_child(
Text::new_inline(
model.model_id.clone(),
appearance.monospace_font_family(),
10.,
)
.with_color(appearance.theme().nonactive_ui_text_color().into())
.with_clip(ClipConfig::end())
.finish(),
)
.finish();
let context_input = appearance
.ui_builder()
.text_input(self.model_context_editors[index].clone())
.with_style(UiComponentStyles {
padding: Some(Coords {
top: 8.,
bottom: 8.,
left: 8.,
right: 8.,
}),
background: Some(appearance.theme().surface_1().into()),
..Default::default()
})
.build()
.finish();
Container::new(
Flex::row()
.with_spacing(12.)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(
ConstrainedBox::new(
appearance
.ui_builder()
.switch(self.model_switches[index].clone())
.check(model.enabled)
.build()
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(
ProviderSetupModalBodyAction::ToggleModel(index),
);
})
.finish(),
)
.with_width(52.)
.finish(),
)
.with_child(ConstrainedBox::new(model_info).with_width(250.).finish())
.with_child(ConstrainedBox::new(context_input).with_width(140.).finish())
.with_child(
ConstrainedBox::new(self.render_model_capabilities(appearance, index))
.with_width(330.)
.finish(),
)
.finish(),
)
.with_padding(Padding::uniform(12.).with_vertical(10.))
.with_border(Border::bottom(1.).with_border_fill(appearance.theme().outline()))
.finish()
}
@@ -1382,12 +1730,70 @@ impl ProviderSetupModalBody {
.finish();
}
if self.provider_type == ProviderSetupProviderType::Acp {
let option_rows = self
.draft_acp
.config_options
.iter()
.filter(|option| {
matches!(
option.category.as_deref(),
Some("model") | Some("thought_level") | Some("mode")
)
})
.map(|option| {
let values = option
.options
.iter()
.map(|value| value.name.as_str())
.collect::<Vec<_>>()
.join(", ");
Flex::column()
.with_spacing(2.)
.with_child(
Text::new(
option.name.clone(),
appearance.ui_font_family(),
INPUT_FONT_SIZE,
)
.with_color(appearance.theme().active_ui_text_color().into())
.with_style(Properties::default().weight(Weight::Bold))
.finish(),
)
.with_child(
Text::new(
if values.is_empty() {
option.current_value.to_string()
} else {
values
},
appearance.monospace_font_family(),
10.,
)
.with_color(appearance.theme().nonactive_ui_text_color().into())
.soft_wrap(true)
.finish(),
)
.finish()
})
.collect::<Vec<_>>();
let catalog = if option_rows.is_empty() {
Text::new(
"No model or mode catalog has been discovered yet. Continue to test the ACP agent.",
appearance.ui_font_family(),
INPUT_FONT_SIZE,
)
.with_color(appearance.theme().nonactive_ui_text_color().into())
.soft_wrap(true)
.finish()
} else {
self.render_model_table(appearance, option_rows, 10.)
};
return Flex::column()
.with_spacing(12.)
.with_child(self.render_input(appearance, "Connection name", &self.name_editor))
.with_child(
Text::new(
"ACP owns model selection. The configured agent runtime was checked before this step.",
"ACP-discovered models and modes are exposed as selectable combinations in Galaxy's model picker.",
appearance.ui_font_family(),
INPUT_FONT_SIZE,
)
@@ -1395,67 +1801,25 @@ impl ProviderSetupModalBody {
.soft_wrap(true)
.finish(),
)
.with_child(catalog)
.finish();
}
let mut rows = Vec::with_capacity(self.draft_models.len());
for (index, model) in self.draft_models.iter().enumerate() {
let modes = if model.reasoning_efforts.is_empty() {
"Standard".to_string()
} else {
model.reasoning_efforts.join(", ")
};
let info = Flex::column()
.with_spacing(2.)
.with_child(
Text::new(
model.display_name.clone(),
appearance.ui_font_family(),
INPUT_FONT_SIZE,
)
.with_color(appearance.theme().active_ui_text_color().into())
.finish(),
)
.with_child(
Text::new(
format!("{} · modes: {modes}", model.model_id),
appearance.monospace_font_family(),
10.,
)
.with_color(appearance.theme().nonactive_ui_text_color().into())
.soft_wrap(true)
.finish(),
)
.finish();
rows.push(
Flex::row()
.with_spacing(10.)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(
appearance
.ui_builder()
.switch(self.model_switches[index].clone())
.check(model.enabled)
.build()
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(
ProviderSetupModalBodyAction::ToggleModel(index),
);
})
.finish(),
)
.with_child(info)
.finish(),
);
}
let mut rows = vec![self.render_model_table_header(appearance)];
rows.extend(
self.draft_models
.iter()
.enumerate()
.map(|(index, model)| self.render_openai_model_row(appearance, index, model)),
);
let table = self.render_model_table(appearance, rows, 12.);
let table = self.render_model_table(appearance, rows, 0.);
Flex::column()
.with_spacing(12.)
.with_child(self.render_input(appearance, "Connection name", &self.name_editor))
.with_child(
Text::new(
"Choose which models Galaxy should make available. Reasoning modes remain selectable from the model picker.",
"Enable the models Galaxy should offer. Context is the maximum input window. Capabilities use Auto by default and can be overridden per model.",
appearance.ui_font_family(),
INPUT_FONT_SIZE,
)
@@ -1631,6 +1995,17 @@ impl TypedActionView for ProviderSetupModalBody {
ProviderSetupModalBodyAction::Cancel => {
ctx.emit(ProviderSetupModalBodyEvent::Close);
}
ProviderSetupModalBodyAction::SelectAcpAgent(agent_id) => {
self.draft_acp.agent_id = agent_id.clone();
if !agent_id.eq_ignore_ascii_case("custom") {
self.draft_acp.command.clear();
self.draft_acp.args.clear();
}
self.sync_editors(ctx);
self.sync_acp_agent_buttons(ctx);
self.update_next_button(ctx);
ctx.notify();
}
ProviderSetupModalBodyAction::ToggleModel(index) => {
if let Some(model) = self.draft_models.get_mut(*index) {
model.enabled = !model.enabled;
@@ -1638,6 +2013,16 @@ impl TypedActionView for ProviderSetupModalBody {
ctx.notify();
}
}
ProviderSetupModalBodyAction::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();
model.capability_overrides.insert(key, next);
self.update_next_button(ctx);
self.sync_model_switches(ctx);
ctx.notify();
}
}
ProviderSetupModalBodyAction::ConnectChatGPT => {
#[cfg(not(target_family = "wasm"))]
ChatGPTAuthModel::handle(ctx).update(ctx, |model, ctx| model.connect(ctx));
+121 -27
View File
@@ -10,12 +10,109 @@ use agent_client_protocol::AcpAgentConfig;
use crate::{DenyByDefaultPermissionHandler, PermissionHandler};
/// Pinned version of the official Codex ACP adapter.
pub const CODEX_ACP_NPM_VERSION: &str = "1.1.7";
/// Version of the official Codex ACP adapter supported by the built-in setup.
pub const CODEX_ACP_NPM_VERSION: &str = "1.1.14";
/// Pinned version of OpenCode used by the built-in ACP launch preset.
pub const OPENCODE_NPM_VERSION: &str = "1.18.9";
/// A known ACP client that can be selected in Galaxy settings.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct AcpKnownAgent {
pub id: &'static str,
pub name: &'static str,
pub description: &'static str,
pub command: &'static str,
pub args: &'static [&'static str],
}
/// Curated ACP Registry catalog. Launch commands are intentionally local-only:
/// Galaxy never installs or downloads an agent on the user's behalf.
pub const KNOWN_ACP_AGENTS: &[AcpKnownAgent] = &[
AcpKnownAgent {
id: "codex",
name: "Codex",
description: "OpenAI's coding assistant",
command: "codex",
args: &[],
},
AcpKnownAgent {
id: "opencode",
name: "OpenCode",
description: "Open source coding agent",
command: "opencode",
args: &["acp"],
},
AcpKnownAgent {
id: "claude-acp",
name: "Claude Agent",
description: "Anthropic's coding agent",
command: "claude-agent-acp",
args: &[],
},
AcpKnownAgent {
id: "gemini",
name: "Gemini CLI",
description: "Google's coding agent",
command: "gemini",
args: &["--acp"],
},
AcpKnownAgent {
id: "cline",
name: "Cline",
description: "Autonomous coding agent",
command: "cline",
args: &["--acp"],
},
AcpKnownAgent {
id: "cursor",
name: "Cursor",
description: "Cursor's coding agent",
command: "cursor-agent",
args: &["acp"],
},
AcpKnownAgent {
id: "github-copilot-cli",
name: "GitHub Copilot",
description: "GitHub's AI pair programmer",
command: "copilot",
args: &["--acp"],
},
AcpKnownAgent {
id: "goose",
name: "Goose",
description: "Block's open source AI agent",
command: "goose",
args: &["acp"],
},
AcpKnownAgent {
id: "auggie",
name: "Auggie CLI",
description: "Augment Code's coding agent",
command: "auggie",
args: &["--acp"],
},
];
pub fn known_acp_agents() -> &'static [AcpKnownAgent] {
KNOWN_ACP_AGENTS
}
/// Resolve a registry-listed agent from the local PATH.
pub fn resolve_known_acp_agent(agent_id: &str) -> Result<AcpLaunchConfig, String> {
let agent = known_acp_agents()
.iter()
.find(|agent| agent.id.eq_ignore_ascii_case(agent_id.trim()))
.ok_or_else(|| format!("Unknown ACP agent: {agent_id:?}"))?;
let command = executable_on_path(agent.command).ok_or_else(|| {
format!(
"{} is not installed or could not be found on PATH (expected `{}`). Install it or choose Custom.",
agent.name, agent.command
)
})?;
Ok(AcpLaunchConfig::new(command).args(agent.args.iter().copied()))
}
const DEFAULT_CANCELLATION_GRACE_PERIOD: Duration = Duration::from_secs(5);
const DEFAULT_INITIALIZATION_TIMEOUT: Duration = Duration::from_secs(30);
const DEFAULT_AUTHENTICATION_TIMEOUT: Duration = Duration::from_secs(5 * 60);
@@ -37,7 +134,7 @@ impl AcpAgentPreset {
pub fn launch_config(self) -> AcpLaunchConfig {
match self {
Self::Codex => AcpLaunchConfig::new("npx")
.args(vec![
.args([
"--yes".to_owned(),
format!("@agentclientprotocol/codex-acp@{CODEX_ACP_NPM_VERSION}"),
])
@@ -61,9 +158,9 @@ impl AcpAgentPreset {
/// Resolves the best available executable for this preset.
///
/// OpenCode's native binary is preferred when installed. The Codex adapter
/// uses `npx` when available and can run through Bun's Node compatibility
/// mode. OpenCode's npm wrapper requires Node during installation.
/// OpenCode's native binary is preferred when installed. Codex runs its ACP
/// adapter through npx, while CODEX_PATH points at the user's installed
/// Codex CLI rather than downloading a second Codex installation.
pub fn resolve_launch_config(self) -> Result<AcpLaunchConfig, String> {
self.resolve_launch_config_with(executable_on_path)
}
@@ -74,33 +171,30 @@ impl AcpAgentPreset {
) -> Result<AcpLaunchConfig, String> {
match self {
Self::Codex => {
let (command, args) = if let Some(command) = resolve("npx") {
(
command,
vec![
"--yes".to_owned(),
format!("@agentclientprotocol/codex-acp@{CODEX_ACP_NPM_VERSION}"),
],
)
} else if let Some(command) = resolve("bunx") {
(
command,
vec![
"--bun".to_owned(),
format!("@agentclientprotocol/codex-acp@{CODEX_ACP_NPM_VERSION}"),
],
)
} else {
let Some(codex) = resolve("codex") else {
return Err(
"Codex ACP requires npx or bunx; install Node.js/npm or Bun, or configure a custom ACP executable"
"Codex ACP requires the locally installed codex CLI; install Codex or configure a custom ACP executable"
.to_owned(),
);
};
Ok(AcpLaunchConfig::new(command)
.args(args)
let launch = if let Some(adapter) = resolve("codex-acp") {
AcpLaunchConfig::new(adapter)
} else if let Some(npx) = resolve("npx") {
AcpLaunchConfig::new(npx).args([
"--yes".to_owned(),
format!("@agentclientprotocol/codex-acp@{CODEX_ACP_NPM_VERSION}"),
])
} else {
return Err(
"Codex ACP requires either a local codex-acp executable or npx; install the ACP adapter, install Node.js/npm, or configure a custom ACP executable"
.to_owned(),
);
};
Ok(launch
.preferred_auth_method("chat-gpt")
.env("DEFAULT_AUTH_REQUEST", r#"{"methodId":"chat-gpt"}"#)
.env("INITIAL_AGENT_MODE", "read-only"))
.env("INITIAL_AGENT_MODE", "read-only")
.codex_path(codex))
}
Self::OpenCode => {
if let Some(command) = resolve("opencode") {
+40 -6
View File
@@ -4,7 +4,7 @@ use std::time::Duration;
use super::*;
#[test]
fn codex_preset_is_version_pinned() {
fn codex_preset_uses_the_adapter_with_npx() {
let launch = AcpAgentPreset::Codex.launch_config();
assert_eq!(launch.command, PathBuf::from("npx"));
@@ -62,20 +62,28 @@ fn resolved_opencode_prefers_the_native_executable() {
}
#[test]
fn resolved_codex_falls_back_to_bun_compatibility_mode() {
let resolve = |command: &str| (command == "bunx").then(|| PathBuf::from("/opt/bin/bunx"));
fn resolved_codex_uses_npx_adapter_and_local_cli() {
let resolve = |command: &str| match command {
"npx" => Some(PathBuf::from("/opt/bin/npx")),
"codex" => Some(PathBuf::from("/opt/homebrew/bin/codex")),
_ => None,
};
let codex = AcpAgentPreset::Codex
.resolve_launch_config_with(resolve)
.unwrap();
assert_eq!(codex.command, PathBuf::from("/opt/bin/bunx"));
assert_eq!(codex.command, PathBuf::from("/opt/bin/npx"));
assert_eq!(
codex.args,
vec![
"--bun".to_owned(),
"--yes".to_owned(),
format!("@agentclientprotocol/codex-acp@{CODEX_ACP_NPM_VERSION}")
]
);
assert_eq!(
codex.env.get("CODEX_PATH").map(String::as_str),
Some("/opt/homebrew/bin/codex")
);
assert_eq!(
codex.env.get("INITIAL_AGENT_MODE").map(String::as_str),
Some("read-only")
@@ -89,13 +97,32 @@ fn resolved_codex_falls_back_to_bun_compatibility_mode() {
);
}
#[test]
fn resolved_codex_falls_back_to_local_adapter_without_npx() {
let resolve = |command: &str| match command {
"codex" => Some(PathBuf::from("/opt/homebrew/bin/codex")),
"codex-acp" => Some(PathBuf::from("/opt/bin/codex-acp")),
_ => None,
};
let codex = AcpAgentPreset::Codex
.resolve_launch_config_with(resolve)
.unwrap();
assert_eq!(codex.command, PathBuf::from("/opt/bin/codex-acp"));
assert!(codex.args.is_empty());
assert_eq!(
codex.env.get("CODEX_PATH").map(String::as_str),
Some("/opt/homebrew/bin/codex")
);
}
#[test]
fn resolved_presets_explain_missing_launchers() {
let error = AcpAgentPreset::Codex
.resolve_launch_config_with(|_| None)
.unwrap_err();
assert!(error.contains("requires npx or bunx"));
assert!(error.contains("requires the locally installed codex CLI"));
let opencode_error = AcpAgentPreset::OpenCode
.resolve_launch_config_with(|command| {
@@ -103,6 +130,13 @@ fn resolved_presets_explain_missing_launchers() {
})
.unwrap_err();
assert!(opencode_error.contains("requires the opencode executable or npx"));
let codex_adapter_error = AcpAgentPreset::Codex
.resolve_launch_config_with(|command| {
(command == "codex").then(|| PathBuf::from("/opt/homebrew/bin/codex"))
})
.unwrap_err();
assert!(codex_adapter_error.contains("requires either a local codex-acp executable or npx"));
}
#[test]
+2 -1
View File
@@ -21,7 +21,8 @@ pub use agent_runtime::{
AcpAgentRuntime, AcpAgentRuntimeConfig, AcpRuntimeState, AcpRuntimeStateHandle,
};
pub use config::{
AcpAgentPreset, AcpLaunchConfig, AcpManagerConfig, CODEX_ACP_NPM_VERSION, OPENCODE_NPM_VERSION,
known_acp_agents, resolve_known_acp_agent, AcpAgentPreset, AcpKnownAgent, AcpLaunchConfig,
AcpManagerConfig, CODEX_ACP_NPM_VERSION, KNOWN_ACP_AGENTS, OPENCODE_NPM_VERSION,
};
pub use events::AcpEvent;
pub use permissions::{
+1 -1
View File
@@ -159,7 +159,7 @@ impl AgentRuntime for ChatGPTSubscriptionRuntime {
request,
self.config.max_output_tokens,
true,
false,
true,
additional_params,
)?;
@@ -95,7 +95,7 @@ fn build_completion_request(
request,
configured_max_output_tokens,
supports_system_messages,
false,
true,
Some(serde_json::json!({
"stream_options": { "include_usage": true }
})),