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 sha2::{Digest as _, Sha256};
use crate::persistence::model::AcpConversationData; use crate::persistence::model::AcpConversationData;
@@ -215,9 +218,11 @@ pub(crate) fn resolve_acp_launch(
match agent_id.trim().to_ascii_lowercase().as_str() { match agent_id.trim().to_ascii_lowercase().as_str() {
"codex" => AcpAgentPreset::Codex.resolve_launch_config(), "codex" => AcpAgentPreset::Codex.resolve_launch_config(),
"opencode" => AcpAgentPreset::OpenCode.resolve_launch_config(), "opencode" => AcpAgentPreset::OpenCode.resolve_launch_config(),
unknown => Err(format!( _ => resolve_known_acp_agent(agent_id).map_err(|error| {
"Unknown ACP agent preset {unknown:?}; choose \"codex\" or \"opencode\", or configure a custom ACP executable" 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() { fn unknown_builtin_agent_ids_are_rejected() {
let error = resolve_acp_launch("mystery-agent", "", &[]).unwrap_err(); let error = resolve_acp_launch("mystery-agent", "", &[]).unwrap_err();
assert!(error.contains("Unknown ACP agent preset")); assert!(error.contains("Unknown ACP agent"));
} }
#[test] #[test]
+8 -5
View File
@@ -509,7 +509,7 @@ fn default_computer_use_llms() -> AvailableLLMs {
}, },
description: None, description: None,
disable_reason: None, disable_reason: None,
vision_supported: true, vision_supported: false,
spec: None, spec: None,
provider: LLMProvider::Unknown, provider: LLMProvider::Unknown,
host_configs: HashMap::new(), host_configs: HashMap::new(),
@@ -1164,7 +1164,7 @@ impl LLMPreferences {
}, },
description: Some(provider_name.clone()), description: Some(provider_name.clone()),
disable_reason: None, disable_reason: None,
vision_supported: model.vision_supported, vision_supported: model.effective_vision_supported(),
spec: None, spec: None,
provider: LLMProvider::LiteLLM, provider: LLMProvider::LiteLLM,
host_configs: HashMap::from([( host_configs: HashMap::from([(
@@ -1670,13 +1670,14 @@ impl LLMPreferences {
.map(|model| OpenAIModelConfig { .map(|model| OpenAIModelConfig {
model_id: model.id, model_id: model.id,
display_name: model.display_name, display_name: model.display_name,
vision_supported: false, vision_supported: true,
context_size: model.context_size.unwrap_or(128_000), context_size: model.context_size.unwrap_or(128_000),
max_input_tokens: model.context_size, max_input_tokens: model.context_size,
max_output_tokens: None, max_output_tokens: None,
provider: None, provider: None,
use_rig: true, use_rig: true,
supports_system_messages: Some(true), supports_system_messages: Some(true),
capability_overrides: std::collections::HashMap::new(),
reasoning_efforts: Vec::new(), reasoning_efforts: Vec::new(),
enabled: true, enabled: true,
}) })
@@ -2594,7 +2595,7 @@ async fn fetch_from_litellm_model_info(
.and_then(|v| u32::try_from(v).ok()); .and_then(|v| u32::try_from(v).ok());
let context_size = max_input_tokens.unwrap_or(200_000); 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 = model_name.replace(['-', '_'], " ");
let display_name = display_name let display_name = display_name
@@ -2651,6 +2652,7 @@ async fn fetch_from_litellm_model_info(
} else { } else {
model_info["supports_system_messages"].as_bool() model_info["supports_system_messages"].as_bool()
}, },
capability_overrides: std::collections::HashMap::new(),
reasoning_efforts: Vec::new(), reasoning_efforts: Vec::new(),
enabled: true, enabled: true,
}) })
@@ -2769,7 +2771,7 @@ async fn fetch_from_openai_models(
vision_supported: m["supports_vision"] vision_supported: m["supports_vision"]
.as_bool() .as_bool()
.or_else(|| m["vision_support"].as_bool()) .or_else(|| m["vision_support"].as_bool())
.unwrap_or(false), .unwrap_or(true),
context_size, context_size,
max_input_tokens, max_input_tokens,
max_output_tokens, max_output_tokens,
@@ -2780,6 +2782,7 @@ async fn fetch_from_openai_models(
} else { } else {
m["supports_system_messages"].as_bool() m["supports_system_messages"].as_bool()
}, },
capability_overrides: std::collections::HashMap::new(),
reasoning_efforts: Vec::new(), reasoning_efforts: Vec::new(),
enabled: true, enabled: true,
}) })
+1
View File
@@ -152,6 +152,7 @@ fn openai_model(model_id: &str) -> OpenAIModelConfig {
provider: None, provider: None,
use_rig: false, use_rig: false,
supports_system_messages: None, supports_system_messages: None,
capability_overrides: std::collections::HashMap::new(),
reasoning_efforts: Vec::new(), reasoning_efforts: Vec::new(),
enabled: true, enabled: true,
} }
+1 -1
View File
@@ -1656,7 +1656,7 @@ impl Element for EditorElement {
); );
} }
if size.x().is_infinite() { 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; 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." description = "Whether this endpoint accepts system-role messages. Set false for ChatGPT-backed LiteLLM models that reject them."
)] )]
pub supports_system_messages: Option<bool>, 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)] #[serde(default)]
#[schemars( #[schemars(
description = "Reasoning effort modes supported by this model when using the ChatGPT subscription provider." 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 settings_value::SettingsValue for OpenAIModelConfig {}
impl 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 { 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-") { if self.model_id.starts_with("codex-gpt-") {
return false; 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, display_name, reasoning_efforts)| OpenAIModelConfig {
model_id: model_id.to_string(), model_id: model_id.to_string(),
display_name: display_name.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(), context_size: default_context_size(),
max_input_tokens: None, max_input_tokens: None,
max_output_tokens: None, max_output_tokens: None,
provider: Some("openai".to_string()), provider: Some("openai".to_string()),
use_rig: true, use_rig: true,
supports_system_messages: Some(true), supports_system_messages: Some(true),
capability_overrides: HashMap::new(),
reasoning_efforts: reasoning_efforts.into_iter().map(str::to_string).collect(), reasoning_efforts: reasoning_efforts.into_iter().map(str::to_string).collect(),
enabled: true, enabled: true,
}, },
@@ -1055,13 +1118,16 @@ fn default_openai_providers() -> Vec<OpenAIProviderConfig> {
models: vec![OpenAIModelConfig { models: vec![OpenAIModelConfig {
model_id: INITIAL_RIG_MODEL_ID.to_string(), model_id: INITIAL_RIG_MODEL_ID.to_string(),
display_name: "Codex GPT-5.6 SOL (xhigh)".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(), context_size: default_context_size(),
max_input_tokens: None, max_input_tokens: None,
max_output_tokens: None, max_output_tokens: None,
provider: Some("openai".to_string()), provider: Some("openai".to_string()),
use_rig: true, use_rig: true,
supports_system_messages: Some(false), supports_system_messages: Some(false),
capability_overrides: HashMap::new(),
reasoning_efforts: Vec::new(), reasoning_efforts: Vec::new(),
enabled: true, enabled: true,
}], }],
+27 -10
View File
@@ -707,13 +707,19 @@ impl AISettingsPageView {
draft: &AcpProviderDraft, draft: &AcpProviderDraft,
ctx: &mut ViewContext<Self>, 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.agent_id,
&draft.command, &draft.command,
&draft.args, &draft.args,
) else { ) {
log::warn!("Could not resolve ACP launch configuration for discovery"); Ok(config) => config,
return; 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); self.start_acp_discovery(config, draft.agent_id.clone(), ctx);
} }
@@ -739,7 +745,7 @@ impl AISettingsPageView {
log::warn!("Could not start ACP discovery: {error}"); log::warn!("Could not start ACP discovery: {error}");
let error_text = error.to_string(); let error_text = error.to_string();
provider_setup_modal_body.update(ctx, |body, 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);
}); });
return; return;
} }
@@ -751,6 +757,10 @@ impl AISettingsPageView {
match result { match result {
Ok(options) => { Ok(options) => {
let option_count = options.len(); let option_count = options.len();
let config_options =
crate::ai::acp::AcpRuntimeModel::normalize_config_options(
options.clone(),
);
AISettings::handle(ctx).update(ctx, |settings, ctx| { AISettings::handle(ctx).update(ctx, |settings, ctx| {
if let Err(error) = if let Err(error) =
crate::ai::acp::AcpRuntimeModel::upsert_agent_settings( crate::ai::acp::AcpRuntimeModel::upsert_agent_settings(
@@ -774,7 +784,7 @@ impl AISettingsPageView {
runtime.finish_discovery_success(option_count, ctx); runtime.finish_discovery_success(option_count, ctx);
}); });
provider_setup_modal_body.update(ctx, |body, 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) => { Err(error) => {
@@ -796,7 +806,7 @@ impl AISettingsPageView {
runtime.finish_discovery_failure(error_text.clone(), ctx); runtime.finish_discovery_failure(error_text.clone(), ctx);
}); });
provider_setup_modal_body.update(ctx, |body, 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, ctx,
) )
.with_modal_style(UiComponentStyles { .with_modal_style(UiComponentStyles {
width: Some(640.), width: Some(900.),
height: Some(600.), height: Some(700.),
..Default::default() ..Default::default()
}) })
.with_body_style(UiComponentStyles { .with_body_style(UiComponentStyles {
height: Some(530.), height: Some(630.),
..Default::default() ..Default::default()
}) })
.with_dismiss_on_click() .with_dismiss_on_click()
@@ -2017,6 +2027,13 @@ impl AISettingsPageView {
agent_id: settings.acp_agent_id.value().clone(), agent_id: settings.acp_agent_id.value().clone(),
command: settings.acp_agent_command.value().clone(), command: settings.acp_agent_command.value().clone(),
args: settings.acp_agent_args.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 let body = self
.provider_setup_modal_state .provider_setup_modal_state
+467 -82
View File
@@ -5,6 +5,7 @@ use galaxyui::elements::{
MouseStateHandle, Padding, ParentElement, Radius, ScrollbarWidth, Text, MouseStateHandle, Padding, ParentElement, Radius, ScrollbarWidth, Text,
}; };
use galaxyui::fonts::{Properties, Weight}; use galaxyui::fonts::{Properties, Weight};
use galaxyui::text_layout::ClipConfig;
use galaxyui::ui_components::button::ButtonVariant; use galaxyui::ui_components::button::ButtonVariant;
use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles}; use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
use galaxyui::ui_components::switch::SwitchStateHandle; use galaxyui::ui_components::switch::SwitchStateHandle;
@@ -21,17 +22,17 @@ use crate::editor::{
}; };
use crate::modal::{Modal, ModalViewState}; use crate::modal::{Modal, ModalViewState};
use crate::settings::ai::{ use crate::settings::ai::{
BedrockAuthMethod, BedrockModelConfig, OpenAIModelConfig, OpenAIProviderConfig, AcpConfigOptionSettings, BedrockAuthMethod, BedrockModelConfig, ModelCapabilityOverride,
OpenAIProviderKind, OpenAIModelConfig, OpenAIProviderConfig, OpenAIProviderKind,
}; };
use crate::ui_components::icons::Icon; use crate::ui_components::icons::Icon;
use crate::view_components::action_button::{ use crate::view_components::action_button::{
ActionButton, NakedTheme, PrimaryTheme, SecondaryTheme, ActionButton, ButtonSize, NakedTheme, PrimaryTheme, SecondaryTheme,
}; };
const MODAL_WIDTH: f32 = 640.; const MODAL_WIDTH: f32 = 900.;
const MODAL_HEIGHT: f32 = 600.; const MODAL_HEIGHT: f32 = 700.;
const BODY_HEIGHT: f32 = 530.; const BODY_HEIGHT: f32 = 630.;
const INPUT_FONT_SIZE: f32 = 12.; const INPUT_FONT_SIZE: f32 = 12.;
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -111,6 +112,7 @@ pub struct AcpProviderDraft {
pub agent_id: String, pub agent_id: String,
pub command: String, pub command: String,
pub args: Vec<String>, pub args: Vec<String>,
pub config_options: Vec<AcpConfigOptionSettings>,
} }
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
@@ -120,6 +122,45 @@ enum DiscoveryState {
Failed(String), 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 { pub enum ProviderSetupModalBodyEvent {
Close, Close,
RequestAcpDiscovery(AcpProviderDraft), RequestAcpDiscovery(AcpProviderDraft),
@@ -138,12 +179,14 @@ pub enum ProviderSetupModalBodyAction {
Back, Back,
Cancel, Cancel,
ToggleModel(usize), ToggleModel(usize),
CycleModelCapability(usize, CapabilityKey),
ConnectChatGPT, ConnectChatGPT,
OpenChatGPTDevicePage, OpenChatGPTDevicePage,
CopyChatGPTDeviceCode, CopyChatGPTDeviceCode,
SelectBedrockAuth(BedrockAuthMethod), SelectBedrockAuth(BedrockAuthMethod),
ToggleBedrockCrossRegion, ToggleBedrockCrossRegion,
ToggleBedrockAutoLogin, ToggleBedrockAutoLogin,
SelectAcpAgent(String),
} }
pub type ProviderSetupModalState = ModalViewState<Modal<ProviderSetupModalBody>>; pub type ProviderSetupModalState = ModalViewState<Modal<ProviderSetupModalBody>>;
@@ -162,6 +205,7 @@ pub struct ProviderSetupModalBody {
draft_acp: AcpProviderDraft, draft_acp: AcpProviderDraft,
discovery_state: DiscoveryState, discovery_state: DiscoveryState,
provider_type_buttons: Vec<ViewHandle<ActionButton>>, provider_type_buttons: Vec<ViewHandle<ActionButton>>,
acp_agent_buttons: Vec<ViewHandle<ActionButton>>,
name_editor: ViewHandle<EditorView>, name_editor: ViewHandle<EditorView>,
base_url_editor: ViewHandle<EditorView>, base_url_editor: ViewHandle<EditorView>,
api_key_editor: ViewHandle<EditorView>, api_key_editor: ViewHandle<EditorView>,
@@ -175,10 +219,16 @@ pub struct ProviderSetupModalBody {
acp_agent_id_editor: ViewHandle<EditorView>, acp_agent_id_editor: ViewHandle<EditorView>,
acp_command_editor: ViewHandle<EditorView>, acp_command_editor: ViewHandle<EditorView>,
acp_args_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_auth_buttons: Vec<ViewHandle<ActionButton>>,
bedrock_cross_region_toggle: SwitchStateHandle, bedrock_cross_region_toggle: SwitchStateHandle,
bedrock_auto_login_toggle: SwitchStateHandle, bedrock_auto_login_toggle: SwitchStateHandle,
model_switches: Vec<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, provider_type_scroll_state: ClippedScrollStateHandle,
models_scroll_state: ClippedScrollStateHandle, models_scroll_state: ClippedScrollStateHandle,
back_button: ViewHandle<ActionButton>, back_button: ViewHandle<ActionButton>,
@@ -219,6 +269,34 @@ impl ProviderSetupModalBody {
let acp_command_editor = Self::create_editor("Optional executable", false, ctx); let acp_command_editor = Self::create_editor("Optional executable", false, ctx);
let acp_args_editor = Self::create_editor(r#"["arg1", "arg2"]"#, 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 = [ let bedrock_auth_buttons = [
BedrockAuthMethod::Profile, BedrockAuthMethod::Profile,
BedrockAuthMethod::Sso, BedrockAuthMethod::Sso,
@@ -358,9 +436,11 @@ impl ProviderSetupModalBody {
agent_id: "codex".to_string(), agent_id: "codex".to_string(),
command: String::new(), command: String::new(),
args: Vec::new(), args: Vec::new(),
config_options: Vec::new(),
}, },
discovery_state: DiscoveryState::Idle, discovery_state: DiscoveryState::Idle,
provider_type_buttons, provider_type_buttons,
acp_agent_buttons,
name_editor, name_editor,
base_url_editor, base_url_editor,
api_key_editor, api_key_editor,
@@ -374,10 +454,16 @@ impl ProviderSetupModalBody {
acp_agent_id_editor, acp_agent_id_editor,
acp_command_editor, acp_command_editor,
acp_args_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_auth_buttons,
bedrock_cross_region_toggle: SwitchStateHandle::default(), bedrock_cross_region_toggle: SwitchStateHandle::default(),
bedrock_auto_login_toggle: SwitchStateHandle::default(), bedrock_auto_login_toggle: SwitchStateHandle::default(),
model_switches: Vec::new(), 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(), provider_type_scroll_state: ClippedScrollStateHandle::default(),
models_scroll_state: ClippedScrollStateHandle::default(), models_scroll_state: ClippedScrollStateHandle::default(),
back_button, back_button,
@@ -440,10 +526,12 @@ impl ProviderSetupModalBody {
agent_id: "codex".to_string(), agent_id: "codex".to_string(),
command: String::new(), command: String::new(),
args: Vec::new(), args: Vec::new(),
config_options: Vec::new(),
}; };
self.discovery_state = DiscoveryState::Idle; self.discovery_state = DiscoveryState::Idle;
self.sync_editors(ctx); self.sync_editors(ctx);
self.sync_provider_type_buttons(ctx); self.sync_provider_type_buttons(ctx);
self.sync_acp_agent_buttons(ctx);
self.sync_bedrock_auth_buttons(ctx); self.sync_bedrock_auth_buttons(ctx);
self.sync_model_switches(ctx); self.sync_model_switches(ctx);
self.update_next_button(ctx); self.update_next_button(ctx);
@@ -457,7 +545,9 @@ impl ProviderSetupModalBody {
provider: OpenAIProviderConfig, provider: OpenAIProviderConfig,
ctx: &mut ViewContext<Self>, 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.editing_index = Some(editing_index);
self.provider_type = match provider.kind { self.provider_type = match provider.kind {
OpenAIProviderKind::ChatGPTSubscription => { OpenAIProviderKind::ChatGPTSubscription => {
@@ -477,6 +567,7 @@ impl ProviderSetupModalBody {
self.discovery_state = DiscoveryState::Idle; self.discovery_state = DiscoveryState::Idle;
self.sync_editors(ctx); self.sync_editors(ctx);
self.sync_provider_type_buttons(ctx); self.sync_provider_type_buttons(ctx);
self.sync_acp_agent_buttons(ctx);
self.sync_bedrock_auth_buttons(ctx); self.sync_bedrock_auth_buttons(ctx);
self.sync_model_switches(ctx); self.sync_model_switches(ctx);
self.update_next_button(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>) { 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.editing_index = None;
self.provider_type = ProviderSetupProviderType::Acp; self.provider_type = ProviderSetupProviderType::Acp;
self.draft_name = draft.name.clone(); self.draft_name = draft.name.clone();
@@ -521,10 +616,12 @@ impl ProviderSetupModalBody {
pub fn finish_acp_discovery( pub fn finish_acp_discovery(
&mut self, &mut self,
result: Result<(), String>, result: Result<(), String>,
config_options: Vec<AcpConfigOptionSettings>,
ctx: &mut ViewContext<Self>, ctx: &mut ViewContext<Self>,
) { ) {
match result { match result {
Ok(()) => { Ok(()) => {
self.draft_acp.config_options = config_options;
self.discovery_state = DiscoveryState::Idle; self.discovery_state = DiscoveryState::Idle;
self.step = ProviderSetupStep::Models; self.step = ProviderSetupStep::Models;
ctx.focus(&self.name_editor); 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>) { fn sync_bedrock_auth_buttons(&self, ctx: &mut ViewContext<Self>) {
for (index, button) in self.bedrock_auth_buttons.iter().enumerate() { for (index, button) in self.bedrock_auth_buttons.iter().enumerate() {
let method = match index { let method = match index {
@@ -612,6 +726,68 @@ impl ProviderSetupModalBody {
self.model_switches.push(SwitchStateHandle::default()); self.model_switches.push(SwitchStateHandle::default());
} }
self.model_switches.truncate(self.draft_models.len()); 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>) { fn update_next_button(&self, ctx: &mut ViewContext<Self>) {
@@ -628,7 +804,11 @@ impl ProviderSetupModalBody {
.is_none_or(|key| key.trim().is_empty()) .is_none_or(|key| key.trim().is_empty())
} }
ProviderSetupProviderType::VertexAI => self.draft_project_id.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::ChatGPTSubscription
| ProviderSetupProviderType::Bedrock => false, | ProviderSetupProviderType::Bedrock => false,
}; };
@@ -957,6 +1137,14 @@ impl ProviderSetupModalBody {
.soft_wrap(true) .soft_wrap(true)
.finish(), .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 { if let ChatGPTAuthState::AwaitingDeviceCode {
verification_uri, verification_uri,
@@ -985,7 +1173,10 @@ impl ProviderSetupModalBody {
.with_child( .with_child(
appearance appearance
.ui_builder() .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()) .with_text_label("Open sign-in page".to_owned())
.build() .build()
.on_click(|ctx, _, _| { .on_click(|ctx, _, _| {
@@ -998,7 +1189,10 @@ impl ProviderSetupModalBody {
.with_child( .with_child(
appearance appearance
.ui_builder() .ui_builder()
.button(ButtonVariant::Secondary, MouseStateHandle::default()) .button(
ButtonVariant::Secondary,
self.chatgpt_copy_mouse_state.clone(),
)
.with_text_label("Copy code".to_owned()) .with_text_label("Copy code".to_owned())
.build() .build()
.on_click(|ctx, _, _| { .on_click(|ctx, _, _| {
@@ -1029,7 +1223,10 @@ impl ProviderSetupModalBody {
children.push( children.push(
appearance appearance
.ui_builder() .ui_builder()
.button(ButtonVariant::Secondary, MouseStateHandle::default()) .button(
ButtonVariant::Secondary,
self.chatgpt_connect_mouse_state.clone(),
)
.with_text_label("Connect ChatGPT".to_owned()) .with_text_label("Connect ChatGPT".to_owned())
.build() .build()
.on_click(|ctx, _, _| { .on_click(|ctx, _, _| {
@@ -1216,24 +1413,32 @@ impl ProviderSetupModalBody {
); );
} }
ProviderSetupProviderType::Acp => { ProviderSetupProviderType::Acp => {
children.push(self.render_input( children.push(
appearance, Text::new("ACP client", appearance.ui_font_family(), INPUT_FONT_SIZE)
"Agent preset", .with_color(appearance.theme().active_ui_text_color().into())
&self.acp_agent_id_editor, .with_style(Properties::default().weight(Weight::Bold))
)); .finish(),
children.push(self.render_input( );
appearance, children.extend(
"Custom executable (optional)", self.acp_agent_buttons
&self.acp_command_editor, .iter()
)); .map(|button| ChildView::new(button).finish()),
children.push(self.render_input( );
appearance, if self.draft_acp.agent_id.eq_ignore_ascii_case("custom") {
"Arguments (JSON array)", children.push(self.render_input(
&self.acp_args_editor, appearance,
)); "Executable",
&self.acp_command_editor,
));
children.push(self.render_input(
appearance,
"Arguments (JSON array)",
&self.acp_args_editor,
));
}
children.push( children.push(
Text::new( 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(), appearance.ui_font_family(),
INPUT_FONT_SIZE, INPUT_FONT_SIZE,
) )
@@ -1330,7 +1535,150 @@ impl ProviderSetupModalBody {
.finish(), .finish(),
) )
.with_width(MODAL_WIDTH - 56.) .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() .finish()
} }
@@ -1382,12 +1730,70 @@ impl ProviderSetupModalBody {
.finish(); .finish();
} }
if self.provider_type == ProviderSetupProviderType::Acp { 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() return Flex::column()
.with_spacing(12.) .with_spacing(12.)
.with_child(self.render_input(appearance, "Connection name", &self.name_editor)) .with_child(self.render_input(appearance, "Connection name", &self.name_editor))
.with_child( .with_child(
Text::new( 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(), appearance.ui_font_family(),
INPUT_FONT_SIZE, INPUT_FONT_SIZE,
) )
@@ -1395,67 +1801,25 @@ impl ProviderSetupModalBody {
.soft_wrap(true) .soft_wrap(true)
.finish(), .finish(),
) )
.with_child(catalog)
.finish(); .finish();
} }
let mut rows = Vec::with_capacity(self.draft_models.len()); let mut rows = vec![self.render_model_table_header(appearance)];
for (index, model) in self.draft_models.iter().enumerate() { rows.extend(
let modes = if model.reasoning_efforts.is_empty() { self.draft_models
"Standard".to_string() .iter()
} else { .enumerate()
model.reasoning_efforts.join(", ") .map(|(index, model)| self.render_openai_model_row(appearance, index, model)),
}; );
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 table = self.render_model_table(appearance, rows, 12.); let table = self.render_model_table(appearance, rows, 0.);
Flex::column() Flex::column()
.with_spacing(12.) .with_spacing(12.)
.with_child(self.render_input(appearance, "Connection name", &self.name_editor)) .with_child(self.render_input(appearance, "Connection name", &self.name_editor))
.with_child( .with_child(
Text::new( 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(), appearance.ui_font_family(),
INPUT_FONT_SIZE, INPUT_FONT_SIZE,
) )
@@ -1631,6 +1995,17 @@ impl TypedActionView for ProviderSetupModalBody {
ProviderSetupModalBodyAction::Cancel => { ProviderSetupModalBodyAction::Cancel => {
ctx.emit(ProviderSetupModalBodyEvent::Close); 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) => { ProviderSetupModalBodyAction::ToggleModel(index) => {
if let Some(model) = self.draft_models.get_mut(*index) { if let Some(model) = self.draft_models.get_mut(*index) {
model.enabled = !model.enabled; model.enabled = !model.enabled;
@@ -1638,6 +2013,16 @@ impl TypedActionView for ProviderSetupModalBody {
ctx.notify(); 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 => { ProviderSetupModalBodyAction::ConnectChatGPT => {
#[cfg(not(target_family = "wasm"))] #[cfg(not(target_family = "wasm"))]
ChatGPTAuthModel::handle(ctx).update(ctx, |model, ctx| model.connect(ctx)); 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}; use crate::{DenyByDefaultPermissionHandler, PermissionHandler};
/// Pinned version of the official Codex ACP adapter. /// Version of the official Codex ACP adapter supported by the built-in setup.
pub const CODEX_ACP_NPM_VERSION: &str = "1.1.7"; pub const CODEX_ACP_NPM_VERSION: &str = "1.1.14";
/// Pinned version of OpenCode used by the built-in ACP launch preset. /// Pinned version of OpenCode used by the built-in ACP launch preset.
pub const OPENCODE_NPM_VERSION: &str = "1.18.9"; 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_CANCELLATION_GRACE_PERIOD: Duration = Duration::from_secs(5);
const DEFAULT_INITIALIZATION_TIMEOUT: Duration = Duration::from_secs(30); const DEFAULT_INITIALIZATION_TIMEOUT: Duration = Duration::from_secs(30);
const DEFAULT_AUTHENTICATION_TIMEOUT: Duration = Duration::from_secs(5 * 60); const DEFAULT_AUTHENTICATION_TIMEOUT: Duration = Duration::from_secs(5 * 60);
@@ -37,7 +134,7 @@ impl AcpAgentPreset {
pub fn launch_config(self) -> AcpLaunchConfig { pub fn launch_config(self) -> AcpLaunchConfig {
match self { match self {
Self::Codex => AcpLaunchConfig::new("npx") Self::Codex => AcpLaunchConfig::new("npx")
.args(vec![ .args([
"--yes".to_owned(), "--yes".to_owned(),
format!("@agentclientprotocol/codex-acp@{CODEX_ACP_NPM_VERSION}"), format!("@agentclientprotocol/codex-acp@{CODEX_ACP_NPM_VERSION}"),
]) ])
@@ -61,9 +158,9 @@ impl AcpAgentPreset {
/// Resolves the best available executable for this preset. /// Resolves the best available executable for this preset.
/// ///
/// OpenCode's native binary is preferred when installed. The Codex adapter /// OpenCode's native binary is preferred when installed. Codex runs its ACP
/// uses `npx` when available and can run through Bun's Node compatibility /// adapter through npx, while CODEX_PATH points at the user's installed
/// mode. OpenCode's npm wrapper requires Node during installation. /// Codex CLI rather than downloading a second Codex installation.
pub fn resolve_launch_config(self) -> Result<AcpLaunchConfig, String> { pub fn resolve_launch_config(self) -> Result<AcpLaunchConfig, String> {
self.resolve_launch_config_with(executable_on_path) self.resolve_launch_config_with(executable_on_path)
} }
@@ -74,33 +171,30 @@ impl AcpAgentPreset {
) -> Result<AcpLaunchConfig, String> { ) -> Result<AcpLaunchConfig, String> {
match self { match self {
Self::Codex => { Self::Codex => {
let (command, args) = if let Some(command) = resolve("npx") { let Some(codex) = resolve("codex") else {
(
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 {
return Err( 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(), .to_owned(),
); );
}; };
Ok(AcpLaunchConfig::new(command) let launch = if let Some(adapter) = resolve("codex-acp") {
.args(args) 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") .preferred_auth_method("chat-gpt")
.env("DEFAULT_AUTH_REQUEST", r#"{"methodId":"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 => { Self::OpenCode => {
if let Some(command) = resolve("opencode") { if let Some(command) = resolve("opencode") {
+40 -6
View File
@@ -4,7 +4,7 @@ use std::time::Duration;
use super::*; use super::*;
#[test] #[test]
fn codex_preset_is_version_pinned() { fn codex_preset_uses_the_adapter_with_npx() {
let launch = AcpAgentPreset::Codex.launch_config(); let launch = AcpAgentPreset::Codex.launch_config();
assert_eq!(launch.command, PathBuf::from("npx")); assert_eq!(launch.command, PathBuf::from("npx"));
@@ -62,20 +62,28 @@ fn resolved_opencode_prefers_the_native_executable() {
} }
#[test] #[test]
fn resolved_codex_falls_back_to_bun_compatibility_mode() { fn resolved_codex_uses_npx_adapter_and_local_cli() {
let resolve = |command: &str| (command == "bunx").then(|| PathBuf::from("/opt/bin/bunx")); 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 let codex = AcpAgentPreset::Codex
.resolve_launch_config_with(resolve) .resolve_launch_config_with(resolve)
.unwrap(); .unwrap();
assert_eq!(codex.command, PathBuf::from("/opt/bin/bunx")); assert_eq!(codex.command, PathBuf::from("/opt/bin/npx"));
assert_eq!( assert_eq!(
codex.args, codex.args,
vec![ vec![
"--bun".to_owned(), "--yes".to_owned(),
format!("@agentclientprotocol/codex-acp@{CODEX_ACP_NPM_VERSION}") 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!( assert_eq!(
codex.env.get("INITIAL_AGENT_MODE").map(String::as_str), codex.env.get("INITIAL_AGENT_MODE").map(String::as_str),
Some("read-only") 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] #[test]
fn resolved_presets_explain_missing_launchers() { fn resolved_presets_explain_missing_launchers() {
let error = AcpAgentPreset::Codex let error = AcpAgentPreset::Codex
.resolve_launch_config_with(|_| None) .resolve_launch_config_with(|_| None)
.unwrap_err(); .unwrap_err();
assert!(error.contains("requires npx or bunx")); assert!(error.contains("requires the locally installed codex CLI"));
let opencode_error = AcpAgentPreset::OpenCode let opencode_error = AcpAgentPreset::OpenCode
.resolve_launch_config_with(|command| { .resolve_launch_config_with(|command| {
@@ -103,6 +130,13 @@ fn resolved_presets_explain_missing_launchers() {
}) })
.unwrap_err(); .unwrap_err();
assert!(opencode_error.contains("requires the opencode executable or npx")); 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] #[test]
+2 -1
View File
@@ -21,7 +21,8 @@ pub use agent_runtime::{
AcpAgentRuntime, AcpAgentRuntimeConfig, AcpRuntimeState, AcpRuntimeStateHandle, AcpAgentRuntime, AcpAgentRuntimeConfig, AcpRuntimeState, AcpRuntimeStateHandle,
}; };
pub use config::{ 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 events::AcpEvent;
pub use permissions::{ pub use permissions::{
+1 -1
View File
@@ -159,7 +159,7 @@ impl AgentRuntime for ChatGPTSubscriptionRuntime {
request, request,
self.config.max_output_tokens, self.config.max_output_tokens,
true, true,
false, true,
additional_params, additional_params,
)?; )?;
@@ -95,7 +95,7 @@ fn build_completion_request(
request, request,
configured_max_output_tokens, configured_max_output_tokens,
supports_system_messages, supports_system_messages,
false, true,
Some(serde_json::json!({ Some(serde_json::json!({
"stream_options": { "include_usage": true } "stream_options": { "include_usage": true }
})), })),