From 1ad2ab4010b55c130a8909f4525214613c2c2989 Mon Sep 17 00:00:00 2001 From: Ryan Ward Date: Wed, 12 Aug 2026 06:38:02 -0500 Subject: [PATCH] adding logging, cleaning up configs --- app/src/ai/acp/launch.rs | 23 + app/src/ai/acp/launch_tests.rs | 2 + app/src/ai/acp/mod.rs | 5 +- app/src/ai/acp/transport.rs | 22 +- app/src/ai/agent/conversation_tests.rs | 3 + app/src/ai/blocklist/action_model.rs | 400 ++++++++++++ app/src/ai/blocklist/controller.rs | 41 +- .../blocklist/controller/response_stream.rs | 583 +++++++++++++++++- app/src/ai/blocklist/controller_tests.rs | 14 + app/src/ai/blocklist/history_model.rs | 30 +- app/src/ai/blocklist/history_model_tests.rs | 1 + app/src/ai/chatgpt_auth.rs | 59 +- app/src/ai/llms.rs | 514 ++++++++++++--- app/src/ai/llms_tests.rs | 198 +++++- app/src/ai/mod.rs | 2 + app/src/ai/remote_logging.rs | 283 +++++++++ app/src/appearance.rs | 79 ++- app/src/settings/ai.rs | 214 ++++++- app/src/settings_view/ai_page.rs | 164 ++++- app/src/settings_view/provider_setup_view.rs | 37 +- crates/persistence/src/model.rs | 3 + crates/persistence/src/model_tests.rs | 3 + script/macos/bundle | 12 +- script/macos/run | 20 + 24 files changed, 2504 insertions(+), 208 deletions(-) create mode 100644 app/src/ai/remote_logging.rs diff --git a/app/src/ai/acp/launch.rs b/app/src/ai/acp/launch.rs index 2713cb0e..a1dbd7d1 100644 --- a/app/src/ai/acp/launch.rs +++ b/app/src/ai/acp/launch.rs @@ -10,6 +10,14 @@ pub(crate) fn acp_model_id(agent_id: &str) -> String { format!("acp:{}", agent_id.trim().to_ascii_lowercase()) } +pub(crate) fn acp_provider_model_id(provider_id: &str, agent_id: &str) -> String { + format!( + "acp:{}:{}", + provider_id.trim().to_ascii_lowercase(), + agent_id.trim().to_ascii_lowercase() + ) +} + pub(crate) fn acp_selection_model_id( agent_id: &str, values: &std::collections::BTreeMap, @@ -26,6 +34,21 @@ pub(crate) fn acp_selection_model_id( } } +pub(crate) fn acp_provider_selection_identity( + provider_id: &str, + agent_id: &str, + values: &std::collections::BTreeMap, +) -> String { + let mut identity = acp_provider_model_id(provider_id, agent_id); + for (key, value) in values { + identity.push(':'); + identity.push_str(key); + identity.push('='); + identity.push_str(&canonical_json_value(value)); + } + identity +} + pub(crate) fn acp_selection_identity( agent_id: &str, values: &std::collections::BTreeMap, diff --git a/app/src/ai/acp/launch_tests.rs b/app/src/ai/acp/launch_tests.rs index c4f2ee6e..34ea1bf8 100644 --- a/app/src/ai/acp/launch_tests.rs +++ b/app/src/ai/acp/launch_tests.rs @@ -116,6 +116,7 @@ fn persisted_sessions_require_the_same_launch_identity() { let args = vec!["serve".to_owned()]; let launch = resolve_acp_launch("custom", command, &args).unwrap(); let backend = AcpConversationData { + provider_id: String::new(), agent_id: "custom".to_owned(), launch_fingerprint: acp_launch_fingerprint("custom", command, &args), session_id: Some("session-123".to_owned()), @@ -139,6 +140,7 @@ fn persisted_sessions_require_the_same_launch_identity() { #[test] fn legacy_acp_sessions_fail_closed_without_a_launch_fingerprint() { let backend = AcpConversationData { + provider_id: String::new(), agent_id: "codex".to_owned(), launch_fingerprint: String::new(), session_id: Some("legacy-session".to_owned()), diff --git a/app/src/ai/acp/mod.rs b/app/src/ai/acp/mod.rs index 597640da..deaaedfd 100644 --- a/app/src/ai/acp/mod.rs +++ b/app/src/ai/acp/mod.rs @@ -11,8 +11,9 @@ mod runtime_model; mod transport; pub(crate) use launch::{ - acp_launch_fingerprint, acp_model_id, acp_selection_identity, acp_selection_model_id, - resolve_acp_launch, validate_acp_dispatch, validate_acp_launch_identity, + acp_launch_fingerprint, acp_model_id, acp_provider_selection_identity, acp_selection_identity, + acp_selection_model_id, resolve_acp_launch, validate_acp_dispatch, + validate_acp_launch_identity, }; pub(crate) use permissions::resolve_acp_permissions; pub(crate) use runtime_model::{AcpDiscoveryState, AcpRuntimeModel}; diff --git a/app/src/ai/acp/transport.rs b/app/src/ai/acp/transport.rs index c4146177..f0aed9e4 100644 --- a/app/src/ai/acp/transport.rs +++ b/app/src/ai/acp/transport.rs @@ -13,7 +13,7 @@ use galaxy_agent_core::{ TurnRequest, }; -use super::launch::acp_selection_identity; +use super::launch::{acp_provider_selection_identity, acp_selection_identity}; use super::prompt::{prompt_content, GalaxyTerminalTools}; use crate::ai::agent::api::{self, RequestParams}; use crate::ai::agent::EntrypointType; @@ -88,7 +88,15 @@ pub(crate) async fn acp_output_stream( if let Some(server) = galaxy_mcp_server { mcp_servers.push(server); } - let runtime_id = acp_selection_identity(&backend.agent_id, &backend.config_values); + let runtime_id = if backend.provider_id.is_empty() { + acp_selection_identity(&backend.agent_id, &backend.config_values) + } else { + acp_provider_selection_identity( + &backend.provider_id, + &backend.agent_id, + &backend.config_values, + ) + }; let mut runtime_config = AcpAgentRuntimeConfig::new(runtime_id.clone(), backend.agent_id.clone(), cwd); runtime_config.config_values = backend @@ -184,7 +192,15 @@ fn response_translator( conversation_id: String::new(), needs_create_task: params.tasks.is_empty(), user_query, - model_id: acp_selection_identity(&backend.agent_id, &backend.config_values), + model_id: if backend.provider_id.is_empty() { + acp_selection_identity(&backend.agent_id, &backend.config_values) + } else { + acp_provider_selection_identity( + &backend.provider_id, + &backend.agent_id, + &backend.config_values, + ) + }, max_context_tokens: None, capabilities: RuntimeCapabilities::session_runtime(), empty_output_message: Some("> ACP agent completed without a text response.".to_owned()), diff --git a/app/src/ai/agent/conversation_tests.rs b/app/src/ai/agent/conversation_tests.rs index adc63740..e5bfcd62 100644 --- a/app/src/ai/agent/conversation_tests.rs +++ b/app/src/ai/agent/conversation_tests.rs @@ -228,6 +228,7 @@ fn acp_session_id_is_updated_only_for_acp_conversations() { false, false, AgentBackend::Acp(AcpConversationData { + provider_id: "provider-1".to_string(), agent_id: "codex-acp".to_string(), launch_fingerprint: "launch-123".to_string(), session_id: None, @@ -238,6 +239,7 @@ fn acp_session_id_is_updated_only_for_acp_conversations() { assert_eq!( acp_conversation.agent_backend(), &AgentBackend::Acp(AcpConversationData { + provider_id: "provider-1".to_string(), agent_id: "codex-acp".to_string(), launch_fingerprint: "launch-123".to_string(), session_id: Some("session-123".to_string()), @@ -256,6 +258,7 @@ fn acp_session_id_is_updated_only_for_acp_conversations() { #[test] fn restored_conversation_uses_persisted_acp_backend() { let backend = AgentBackend::Acp(AcpConversationData { + provider_id: "provider-1".to_string(), agent_id: "codex-acp".to_string(), launch_fingerprint: "launch-123".to_string(), session_id: Some("session-123".to_string()), diff --git a/app/src/ai/blocklist/action_model.rs b/app/src/ai/blocklist/action_model.rs index 8f4c2fcc..67789928 100644 --- a/app/src/ai/blocklist/action_model.rs +++ b/app/src/ai/blocklist/action_model.rs @@ -56,10 +56,21 @@ use crate::ai::agent::{ CancellationOutcome, CancellationReason, CreateDocumentsResult, EditDocumentsResult, RequestCommandOutputResult, }; +#[cfg(not(target_family = "wasm"))] +use crate::ai::agent::{ + AskUserQuestionResult, CallMCPToolResult, FetchConversationResult, FileGlobResult, + FileGlobV2Result, GrepResult, InsertReviewCommentsResult, ReadDocumentsResult, ReadFilesResult, + ReadMCPResourceResult, ReadShellCommandOutputResult, ReadSkillResult, RequestComputerUseResult, + RequestFileEditsResult, RunAgentsResult, SearchCodebaseResult, SendMessageToAgentResult, + StartAgentResult, TransferShellCommandControlToUserResult, UploadArtifactResult, + UseComputerResult, WriteToLongRunningShellCommandResult, +}; use crate::ai::ai_document_view::DEFAULT_PLANNING_DOCUMENT_TITLE; use crate::ai::blocklist::action_model::execute::suggest_new_conversation::SuggestNewConversationExecutor; use crate::ai::document::ai_document_model::AIDocumentModel; use crate::ai::get_relevant_files::controller::GetRelevantFilesController; +#[cfg(not(target_family = "wasm"))] +use crate::ai::remote_logging::{self, RemoteLogLevel, RemoteLogRecord}; use crate::terminal::model::session::active_session::ActiveSession; use crate::terminal::model_events::ModelEventDispatcher; use crate::terminal::TerminalModel; @@ -280,6 +291,274 @@ fn domain_tool_result(action_result: &AIAgentActionResult, permission_denied: bo } } +#[cfg(not(target_family = "wasm"))] +fn action_tool_name(action: &AIAgentAction) -> String { + action + .tool_name + .clone() + .unwrap_or_else(|| format!("{:?}", AIAgentActionTypeDiscriminants::from(&action.action))) +} + +#[cfg(not(target_family = "wasm"))] +fn action_result_type_name(result: &AIAgentActionResultType) -> &'static str { + match result { + AIAgentActionResultType::RequestCommandOutput(_) => "RequestCommandOutput", + AIAgentActionResultType::WriteToLongRunningShellCommand(_) => { + "WriteToLongRunningShellCommand" + } + AIAgentActionResultType::RequestFileEdits(_) => "RequestFileEdits", + AIAgentActionResultType::ReadFiles(_) => "ReadFiles", + AIAgentActionResultType::UploadArtifact(_) => "UploadArtifact", + AIAgentActionResultType::SearchCodebase(_) => "SearchCodebase", + AIAgentActionResultType::Grep(_) => "Grep", + AIAgentActionResultType::FileGlob(_) => "FileGlob", + AIAgentActionResultType::FileGlobV2(_) => "FileGlobV2", + AIAgentActionResultType::ReadMCPResource(_) => "ReadMCPResource", + AIAgentActionResultType::CallMCPTool(_) => "CallMCPTool", + AIAgentActionResultType::ReadSkill(_) => "ReadSkill", + AIAgentActionResultType::SuggestNewConversation(_) => "SuggestNewConversation", + AIAgentActionResultType::SuggestPrompt(_) => "SuggestPrompt", + AIAgentActionResultType::OpenCodeReview => "OpenCodeReview", + AIAgentActionResultType::InsertReviewComments(_) => "InsertReviewComments", + AIAgentActionResultType::InitProject => "InitProject", + AIAgentActionResultType::ReadDocuments(_) => "ReadDocuments", + AIAgentActionResultType::EditDocuments(_) => "EditDocuments", + AIAgentActionResultType::CreateDocuments(_) => "CreateDocuments", + AIAgentActionResultType::ReadShellCommandOutput(_) => "ReadShellCommandOutput", + AIAgentActionResultType::UseComputer(_) => "UseComputer", + AIAgentActionResultType::RequestComputerUse(_) => "RequestComputerUse", + AIAgentActionResultType::FetchConversation(_) => "FetchConversation", + AIAgentActionResultType::StartAgent(_) => "StartAgent", + AIAgentActionResultType::SendMessageToAgent(_) => "SendMessageToAgent", + AIAgentActionResultType::TransferShellCommandControlToUser(_) => { + "TransferShellCommandControlToUser" + } + AIAgentActionResultType::AskUserQuestion(_) => "AskUserQuestion", + AIAgentActionResultType::RunAgents(_) => "RunAgents", + AIAgentActionResultType::WaitForEvents(_) => "WaitForEvents", + } +} + +#[cfg(not(target_family = "wasm"))] +fn action_result_status(result: &AIAgentActionResultType) -> &'static str { + if result.is_successful() { + "success" + } else if result.is_failed() || action_result_failure_summary(result).is_some() { + "error" + } else if result.is_cancelled() { + "cancelled" + } else { + "unknown" + } +} + +#[cfg(not(target_family = "wasm"))] +fn action_result_log_level(result: &AIAgentActionResultType) -> RemoteLogLevel { + if result.is_failed() || action_result_failure_summary(result).is_some() { + RemoteLogLevel::Error + } else if result.is_cancelled() { + RemoteLogLevel::Warn + } else { + RemoteLogLevel::Info + } +} + +#[cfg(not(target_family = "wasm"))] +fn action_result_error_summary(result: &AIAgentActionResultType) -> Option { + if let Some(summary) = action_result_failure_summary(result) { + Some(remote_logging::sanitize_error(summary)) + } else if result.is_cancelled() { + Some(format!("{} cancelled", action_result_type_name(result))) + } else { + None + } +} + +#[cfg(not(target_family = "wasm"))] +fn action_result_failure_summary(result: &AIAgentActionResultType) -> Option { + match result { + AIAgentActionResultType::RequestCommandOutput(RequestCommandOutputResult::Completed { + exit_code, + .. + }) if !exit_code.was_successful() => { + Some(format!("command exited with code {}", exit_code.value())) + } + AIAgentActionResultType::RequestCommandOutput(RequestCommandOutputResult::Denylisted { + .. + }) => Some("command was denylisted".to_string()), + AIAgentActionResultType::WriteToLongRunningShellCommand( + WriteToLongRunningShellCommandResult::CommandFinished { exit_code, .. }, + ) if !exit_code.was_successful() => Some(format!( + "long-running shell command exited with code {}", + exit_code.value() + )), + AIAgentActionResultType::WriteToLongRunningShellCommand( + WriteToLongRunningShellCommandResult::Error(error), + ) => Some(format!("{error:?}")), + AIAgentActionResultType::RequestFileEdits( + RequestFileEditsResult::DiffApplicationFailed { error }, + ) => Some(error.clone()), + AIAgentActionResultType::ReadFiles(ReadFilesResult::Error(error)) + | AIAgentActionResultType::UploadArtifact(UploadArtifactResult::Error(error)) + | AIAgentActionResultType::Grep(GrepResult::Error(error)) + | AIAgentActionResultType::FileGlob(FileGlobResult::Error(error)) + | AIAgentActionResultType::FileGlobV2(FileGlobV2Result::Error(error)) + | AIAgentActionResultType::ReadMCPResource(ReadMCPResourceResult::Error(error)) + | AIAgentActionResultType::CallMCPTool(CallMCPToolResult::Error(error)) + | AIAgentActionResultType::ReadSkill(ReadSkillResult::Error(error)) + | AIAgentActionResultType::ReadDocuments(ReadDocumentsResult::Error(error)) + | AIAgentActionResultType::EditDocuments(EditDocumentsResult::Error(error)) + | AIAgentActionResultType::CreateDocuments(CreateDocumentsResult::Error(error)) + | AIAgentActionResultType::UseComputer(UseComputerResult::Error(error)) + | AIAgentActionResultType::RequestComputerUse(RequestComputerUseResult::Error(error)) + | AIAgentActionResultType::FetchConversation(FetchConversationResult::Error(error)) + | AIAgentActionResultType::SendMessageToAgent(SendMessageToAgentResult::Error(error)) + | AIAgentActionResultType::AskUserQuestion(AskUserQuestionResult::Error(error)) => { + Some(error.clone()) + } + AIAgentActionResultType::SearchCodebase(SearchCodebaseResult::Failed { + reason, + message, + }) => Some(format!("{reason:?}: {message}")), + AIAgentActionResultType::ReadShellCommandOutput( + ReadShellCommandOutputResult::CommandFinished { exit_code, .. }, + ) if !exit_code.was_successful() => Some(format!( + "shell command output exited with code {}", + exit_code.value() + )), + AIAgentActionResultType::ReadShellCommandOutput(ReadShellCommandOutputResult::Error( + error, + )) => Some(format!("{error:?}")), + AIAgentActionResultType::InsertReviewComments(InsertReviewCommentsResult::Error { + message, + .. + }) => Some(message.clone()), + AIAgentActionResultType::StartAgent(StartAgentResult::Error { error, .. }) => { + Some(error.clone()) + } + AIAgentActionResultType::TransferShellCommandControlToUser( + TransferShellCommandControlToUserResult::CommandFinished { exit_code, .. }, + ) if !exit_code.was_successful() => Some(format!( + "transferred shell command exited with code {}", + exit_code.value() + )), + AIAgentActionResultType::TransferShellCommandControlToUser( + TransferShellCommandControlToUserResult::Error(error), + ) => Some(format!("{error:?}")), + AIAgentActionResultType::RunAgents(RunAgentsResult::Denied { reason }) => { + Some(reason.clone()) + } + AIAgentActionResultType::RunAgents(RunAgentsResult::Failure { error }) => { + Some(error.clone()) + } + AIAgentActionResultType::RequestCommandOutput( + RequestCommandOutputResult::Completed { .. } + | RequestCommandOutputResult::CancelledBeforeExecution + | RequestCommandOutputResult::LongRunningCommandSnapshot { .. }, + ) + | AIAgentActionResultType::WriteToLongRunningShellCommand( + WriteToLongRunningShellCommandResult::Cancelled + | WriteToLongRunningShellCommandResult::CommandFinished { .. } + | WriteToLongRunningShellCommandResult::Snapshot { .. }, + ) + | AIAgentActionResultType::RequestFileEdits( + RequestFileEditsResult::Cancelled | RequestFileEditsResult::Success { .. }, + ) + | AIAgentActionResultType::ReadFiles( + ReadFilesResult::Success { .. } | ReadFilesResult::Cancelled, + ) + | AIAgentActionResultType::UploadArtifact( + UploadArtifactResult::Success { .. } | UploadArtifactResult::Cancelled, + ) + | AIAgentActionResultType::SearchCodebase( + SearchCodebaseResult::Success { .. } | SearchCodebaseResult::Cancelled, + ) + | AIAgentActionResultType::Grep(GrepResult::Success { .. } | GrepResult::Cancelled) + | AIAgentActionResultType::FileGlob( + FileGlobResult::Success { .. } | FileGlobResult::Cancelled, + ) + | AIAgentActionResultType::FileGlobV2( + FileGlobV2Result::Success { .. } | FileGlobV2Result::Cancelled, + ) + | AIAgentActionResultType::ReadMCPResource( + ReadMCPResourceResult::Success { .. } | ReadMCPResourceResult::Cancelled, + ) + | AIAgentActionResultType::CallMCPTool( + CallMCPToolResult::Success { .. } | CallMCPToolResult::Cancelled, + ) + | AIAgentActionResultType::ReadSkill( + ReadSkillResult::Success { .. } | ReadSkillResult::Cancelled, + ) + | AIAgentActionResultType::SuggestNewConversation(_) + | AIAgentActionResultType::SuggestPrompt(_) + | AIAgentActionResultType::OpenCodeReview + | AIAgentActionResultType::InsertReviewComments( + InsertReviewCommentsResult::Success { .. } | InsertReviewCommentsResult::Cancelled, + ) + | AIAgentActionResultType::InitProject + | AIAgentActionResultType::ReadDocuments( + ReadDocumentsResult::Success { .. } | ReadDocumentsResult::Cancelled, + ) + | AIAgentActionResultType::EditDocuments( + EditDocumentsResult::Success { .. } | EditDocumentsResult::Cancelled, + ) + | AIAgentActionResultType::CreateDocuments( + CreateDocumentsResult::Success { .. } | CreateDocumentsResult::Cancelled, + ) + | AIAgentActionResultType::ReadShellCommandOutput( + ReadShellCommandOutputResult::Cancelled + | ReadShellCommandOutputResult::CommandFinished { .. } + | ReadShellCommandOutputResult::LongRunningCommandSnapshot { .. }, + ) + | AIAgentActionResultType::UseComputer( + UseComputerResult::Success(_) | UseComputerResult::Cancelled, + ) + | AIAgentActionResultType::RequestComputerUse( + RequestComputerUseResult::Approved { .. } | RequestComputerUseResult::Cancelled, + ) + | AIAgentActionResultType::FetchConversation( + FetchConversationResult::Success { .. } | FetchConversationResult::Cancelled, + ) + | AIAgentActionResultType::StartAgent( + StartAgentResult::Success { .. } | StartAgentResult::Cancelled { .. }, + ) + | AIAgentActionResultType::SendMessageToAgent( + SendMessageToAgentResult::Success { .. } | SendMessageToAgentResult::Cancelled, + ) + | AIAgentActionResultType::TransferShellCommandControlToUser( + TransferShellCommandControlToUserResult::Cancelled + | TransferShellCommandControlToUserResult::CommandFinished { .. } + | TransferShellCommandControlToUserResult::Snapshot { .. }, + ) + | AIAgentActionResultType::AskUserQuestion( + AskUserQuestionResult::Success { .. } + | AskUserQuestionResult::Cancelled + | AskUserQuestionResult::SkippedByAutoApprove { .. }, + ) + | AIAgentActionResultType::RunAgents( + RunAgentsResult::Launched { .. } | RunAgentsResult::Cancelled, + ) + | AIAgentActionResultType::WaitForEvents(_) => None, + } +} + +#[cfg(not(target_family = "wasm"))] +fn log_tool_event( + ctx: &mut ModelContext, + level: RemoteLogLevel, + message: &str, + context: serde_json::Value, +) { + remote_logging::log_model_event( + ctx, + RemoteLogRecord { + level, + message: message.to_string(), + context, + }, + ); +} + pub struct BlocklistAIActionModel { executor: ModelHandle, @@ -927,6 +1206,21 @@ impl BlocklistAIActionModel { ctx: &mut ModelContext, ) { if reason.needs_confirmation() { + #[cfg(not(target_family = "wasm"))] + log_tool_event( + ctx, + RemoteLogLevel::Info, + "Tool permission requested", + serde_json::json!({ + "event": "tool_permission_requested", + "conversation_id": conversation_id.to_string(), + "action_id": action.id.to_string(), + "task_id": action.task_id.to_string(), + "tool_name": action_tool_name(action), + "permission_kind": format!("{:?}", permission_kind_for_action(&action.action)), + "reason": format!("{reason:?}"), + }), + ); ctx.emit(BlocklistAIActionEvent::ActionBlockedOnUserConfirmation( action.id.clone(), )); @@ -1005,7 +1299,27 @@ impl BlocklistAIActionModel { let action_id = action.id.clone(); let phase = self.action_phase_for_action(&action, ctx); + #[cfg(not(target_family = "wasm"))] + let remote_log_action_context = serde_json::json!({ + "conversation_id": conversation_id.to_string(), + "action_id": action_id.to_string(), + "task_id": action.task_id.to_string(), + "tool_name": action_tool_name(&action), + "permission_kind": format!("{:?}", permission_kind_for_action(&action.action)), + "phase": format!("{phase:?}"), + }); if is_user_initiated { + #[cfg(not(target_family = "wasm"))] + log_tool_event( + ctx, + RemoteLogLevel::Info, + "Tool permission resolved", + serde_json::json!({ + "event": "tool_permission_resolved", + "decision": "allow_once", + "tool": remote_log_action_context, + }), + ); ctx.emit(BlocklistAIActionEvent::ToolLifecycle { action_id: action_id.clone(), event: ToolEvent::PermissionResolved { @@ -1024,6 +1338,17 @@ impl BlocklistAIActionModel { match execute_result { TryExecuteResult::ExecutedAsync => { + #[cfg(not(target_family = "wasm"))] + log_tool_event( + ctx, + RemoteLogLevel::Info, + "Tool execution started", + serde_json::json!({ + "event": "tool_execution_started", + "initiated_by": if is_user_initiated { "user" } else { "auto" }, + "tool": remote_log_action_context, + }), + ); if !is_wait_for_events { self.update_conversation_in_progress_status(conversation_id, ctx); } @@ -1031,6 +1356,17 @@ impl BlocklistAIActionModel { Some(StartedAction::Async { phase }) } TryExecuteResult::ExecutedSync => { + #[cfg(not(target_family = "wasm"))] + log_tool_event( + ctx, + RemoteLogLevel::Info, + "Tool execution started", + serde_json::json!({ + "event": "tool_execution_started", + "initiated_by": if is_user_initiated { "user" } else { "auto" }, + "tool": remote_log_action_context, + }), + ); if !is_wait_for_events { self.update_conversation_in_progress_status(conversation_id, ctx); } @@ -1079,6 +1415,29 @@ impl BlocklistAIActionModel { std::mem::discriminant(&action.action) ); } + #[cfg(not(target_family = "wasm"))] + log_tool_event( + ctx, + RemoteLogLevel::Info, + "Tools queued", + serde_json::json!({ + "event": "tools_queued", + "conversation_id": conversation_id.to_string(), + "tool_count": actions.len(), + "tools": actions + .iter() + .map(|action| { + serde_json::json!({ + "action_id": action.id.to_string(), + "task_id": action.task_id.to_string(), + "tool_name": action_tool_name(action), + "requires_result": action.requires_result, + "permission_kind": format!("{:?}", permission_kind_for_action(&action.action)), + }) + }) + .collect::>(), + }), + ); self.action_order.insert( conversation_id, actions @@ -1309,6 +1668,21 @@ impl BlocklistAIActionModel { if permission_denied { self.denied_permissions .insert((conversation_id, pending_action.id.clone())); + #[cfg(not(target_family = "wasm"))] + log_tool_event( + ctx, + RemoteLogLevel::Warn, + "Tool permission resolved", + serde_json::json!({ + "event": "tool_permission_resolved", + "decision": "denied", + "conversation_id": conversation_id.to_string(), + "action_id": pending_action.id.to_string(), + "task_id": pending_action.task_id.to_string(), + "tool_name": action_tool_name(&pending_action), + "permission_kind": format!("{:?}", permission_kind_for_action(&pending_action.action)), + }), + ); ctx.emit(BlocklistAIActionEvent::ToolLifecycle { action_id: pending_action.id.clone(), event: ToolEvent::PermissionResolved { @@ -1476,6 +1850,32 @@ impl BlocklistAIActionModel { .entry(conversation_id) .or_default() .push(tool_result.clone()); + #[cfg(not(target_family = "wasm"))] + log_tool_event( + ctx, + if permission_denied { + RemoteLogLevel::Warn + } else { + action_result_log_level(&action_result.result) + }, + "Tool execution completed", + serde_json::json!({ + "event": "tool_execution_completed", + "conversation_id": conversation_id.to_string(), + "action_id": action_result.id.to_string(), + "task_id": action_result.task_id.to_string(), + "result_type": action_result_type_name(&action_result.result), + "status": if permission_denied { + "denied" + } else { + action_result_status(&action_result.result) + }, + "tool_result_status": format!("{:?}", tool_result.status), + "permission_denied": permission_denied, + "cancellation_reason": cancellation_reason.map(|reason| format!("{reason:?}")), + "error": action_result_error_summary(&action_result.result), + }), + ); ctx.emit(BlocklistAIActionEvent::ToolLifecycle { action_id: action_result.id.clone(), event: ToolEvent::Completed { diff --git a/app/src/ai/blocklist/controller.rs b/app/src/ai/blocklist/controller.rs index 382d16df..9ce8d3cd 100644 --- a/app/src/ai/blocklist/controller.rs +++ b/app/src/ai/blocklist/controller.rs @@ -26,6 +26,7 @@ use itertools::Itertools; use parking_lot::FairMutex; use pending_response_streams::PendingResponseStreams; use session_sharing_protocol::common::ParticipantId; +use settings::Setting; pub use slash_command::*; use warp_multi_agent_api::{message, Task, ToolType}; use warpui::r#async::{SpawnedFutureHandle, Timer}; @@ -220,9 +221,18 @@ enum RunningCommandDetection { fn acp_backend_model_id(backend: &AgentBackend) -> Option { match backend { AgentBackend::Provider => None, - AgentBackend::Acp(acp) => { - Some(crate::ai::acp::acp_selection_identity(&acp.agent_id, &acp.config_values).into()) - } + AgentBackend::Acp(acp) => Some( + if acp.provider_id.is_empty() { + crate::ai::acp::acp_selection_identity(&acp.agent_id, &acp.config_values) + } else { + crate::ai::acp::acp_provider_selection_identity( + &acp.provider_id, + &acp.agent_id, + &acp.config_values, + ) + } + .into(), + ), } } @@ -3837,7 +3847,9 @@ impl BlocklistAIController { .map(|conversation| { ( match conversation.agent_backend() { - AgentBackend::Acp(acp) => Some(acp.agent_id.clone()), + AgentBackend::Acp(acp) => { + Some((acp.provider_id.clone(), acp.agent_id.clone())) + } AgentBackend::Provider => None, }, conversation @@ -3853,10 +3865,29 @@ impl BlocklistAIController { #[cfg(not(target_family = "wasm"))] if let Some(metadata) = response_stream.as_ref(ctx).acp_session_metadata() { if !metadata.config_options.is_empty() { - if let Some(agent_id) = &agent_id { + if let Some((provider_id, agent_id)) = &agent_id { crate::settings::AISettings::handle(ctx).update( ctx, |settings, ctx| { + let normalized_options = + crate::ai::acp::AcpRuntimeModel::normalize_config_options( + metadata.config_options.clone(), + ); + let mut providers = + settings.acp_providers.value().clone(); + if let Some(provider) = providers + .iter_mut() + .find(|provider| provider.id == provider_id.as_str()) + { + provider.config_options = normalized_options; + if let Err(error) = + settings.acp_providers.set_value(providers, ctx) + { + log::warn!( + "Failed to persist ACP provider runtime config: {error}" + ); + } + } if let Err(error) = crate::ai::acp::AcpRuntimeModel::persist_runtime_options( settings, diff --git a/app/src/ai/blocklist/controller/response_stream.rs b/app/src/ai/blocklist/controller/response_stream.rs index 8c1f2c49..0a57770d 100644 --- a/app/src/ai/blocklist/controller/response_stream.rs +++ b/app/src/ai/blocklist/controller/response_stream.rs @@ -38,6 +38,8 @@ use crate::ai::blocklist::BlocklistAIPermissions; use crate::ai::llms::{LLMId, LLMPreferences}; use crate::ai::openai::client::OpenAIClientConfig; use crate::ai::provider::ProviderConfig; +#[cfg(not(target_family = "wasm"))] +use crate::ai::remote_logging::{self, RemoteLogLevel, RemoteLogRecord}; use crate::ai::runtime::ProviderRuntime; use crate::network::NetworkStatus; #[cfg(not(target_family = "wasm"))] @@ -142,6 +144,10 @@ pub struct ResponseStream { has_received_client_actions: bool, /// AI identifiers for telemetry emission ai_identifiers: AIIdentifiers, + #[cfg(not(target_family = "wasm"))] + remote_log_backend: String, + #[cfg(not(target_family = "wasm"))] + remote_log_provider: String, /// Whether this request can attempt to resume the conversation on error. /// This is true for all requests except those that are themselves the result of a resume @@ -208,6 +214,10 @@ impl ResponseStream { original_error: None, has_received_client_actions: false, ai_identifiers: AIIdentifiers::default(), + #[cfg(not(target_family = "wasm"))] + remote_log_backend: "provider".to_string(), + #[cfg(not(target_family = "wasm"))] + remote_log_provider: "test".to_string(), can_attempt_resume_on_error: false, should_resume_conversation_after_stream_finished: false, stream_finished_received: false, @@ -292,6 +302,250 @@ impl ResponseStream { ProviderConfig::None } + #[cfg(not(target_family = "wasm"))] + fn remote_log_provider_for_config(provider_config: &ProviderConfig) -> String { + match provider_config { + ProviderConfig::Bedrock(config) => { + let region = if config.region.trim().is_empty() { + "auto" + } else { + config.region.as_str() + }; + format!("bedrock:{:?}:region={region}", config.auth_method) + } + ProviderConfig::OpenAI(config) => { + format!("openai:{:?}:rig={}", config.kind, config.use_rig) + } + ProviderConfig::None => "none".to_string(), + } + } + + #[cfg(not(target_family = "wasm"))] + fn common_remote_log_context( + &self, + event: &str, + request_id: Uuid, + ) -> serde_json::Map { + let mut context = serde_json::Map::new(); + context.insert("event".to_string(), serde_json::json!(event)); + context.insert("stream_id".to_string(), serde_json::json!(self.id.0)); + context.insert( + "request_id".to_string(), + serde_json::json!(request_id.to_string()), + ); + context.insert( + "model_id".to_string(), + serde_json::json!(self.params.model.as_str()), + ); + context.insert( + "backend".to_string(), + serde_json::json!(self.remote_log_backend), + ); + context.insert( + "provider".to_string(), + serde_json::json!(self.remote_log_provider), + ); + context.insert( + "retry_count".to_string(), + serde_json::json!(self.retry_count), + ); + context.insert( + "has_received_client_actions".to_string(), + serde_json::json!(self.has_received_client_actions), + ); + context.insert( + "can_attempt_resume_on_error".to_string(), + serde_json::json!(self.can_attempt_resume_on_error), + ); + context.insert( + "identifiers".to_string(), + serde_json::to_value(&self.ai_identifiers).unwrap_or_else(|_| serde_json::json!({})), + ); + context + } + + #[cfg(not(target_family = "wasm"))] + fn log_llm_request_started( + ctx: &mut ModelContext, + stream_id: &ResponseStreamId, + request_id: Uuid, + params: &api::RequestParams, + ai_identifiers: &AIIdentifiers, + backend: &str, + provider: &str, + can_attempt_resume_on_error: bool, + ) { + remote_logging::log_model_event( + ctx, + RemoteLogRecord { + level: RemoteLogLevel::Info, + message: "LLM request started".to_string(), + context: serde_json::json!({ + "event": "llm_request_started", + "stream_id": stream_id.0, + "request_id": request_id.to_string(), + "model_id": params.model.as_str(), + "coding_model_id": params.coding_model.as_str(), + "backend": backend, + "provider": provider, + "input_count": params.input.len(), + "tool_result_count": params.tool_results.len(), + "task_count": params.tasks.len(), + "message_history_count": params.message_history.len(), + "has_progressive_summary": params.progressive_summary.is_some(), + "memory_enabled": params.is_memory_enabled, + "warp_drive_context_enabled": params.warp_drive_context_enabled, + "planning_enabled": params.planning_enabled, + "web_search_enabled": params.web_search_enabled, + "computer_use_enabled": params.computer_use_enabled, + "ask_user_question_enabled": params.ask_user_question_enabled, + "orchestration_enabled": params.orchestration_enabled, + "is_remote_session": params.session_context.is_remote(), + "can_attempt_resume_on_error": can_attempt_resume_on_error, + "identifiers": serde_json::to_value(ai_identifiers).unwrap_or_else(|_| serde_json::json!({})), + }), + }, + ); + if let Some(raw_payload) = + remote_logging::raw_model_payload_context(ctx, raw_model_request_payload(params)) + { + remote_logging::log_model_event( + ctx, + RemoteLogRecord { + level: RemoteLogLevel::Info, + message: "Raw model request".to_string(), + context: serde_json::json!({ + "event": "raw_model_request", + "stream_id": stream_id.0, + "request_id": request_id.to_string(), + "model_id": params.model.as_str(), + "backend": backend, + "provider": provider, + "raw_payload": raw_payload, + }), + }, + ); + } + } + + #[cfg(not(target_family = "wasm"))] + fn log_galaxy_decision( + &self, + request_id: Uuid, + decision: &str, + details: serde_json::Value, + ctx: &mut ModelContext, + ) { + let mut context = self.common_remote_log_context("galaxy_decision", request_id); + context.insert("decision".to_string(), serde_json::json!(decision)); + context.insert("details".to_string(), details); + remote_logging::log_model_event( + ctx, + RemoteLogRecord { + level: RemoteLogLevel::Info, + message: format!("Galaxy decision: {decision}"), + context: serde_json::Value::Object(context), + }, + ); + } + + #[cfg(not(target_family = "wasm"))] + fn log_llm_response_finished( + &self, + request_id: Uuid, + finished_event: &warp_multi_agent_api::response_event::StreamFinished, + ctx: &mut ModelContext, + ) { + let mut context = self.common_remote_log_context("llm_response_finished", request_id); + context.insert( + "reason".to_string(), + serde_json::json!(stream_finished_reason_name(&finished_event.reason)), + ); + context.insert( + "elapsed_ms".to_string(), + serde_json::json!(self.time_to_latest_event.num_milliseconds()), + ); + context.insert( + "should_refresh_model_config".to_string(), + serde_json::json!(finished_event.should_refresh_model_config), + ); + context.insert( + "token_usage".to_string(), + token_usage_context(&finished_event.token_usage), + ); + if let Some(cost) = finished_event.request_cost.as_ref() { + context.insert( + "request_cost".to_string(), + serde_json::json!({ + "exact": cost.exact, + "platform_credits": cost.platform_credits, + }), + ); + } + remote_logging::log_model_event( + ctx, + RemoteLogRecord { + level: RemoteLogLevel::Info, + message: "LLM response finished".to_string(), + context: serde_json::Value::Object(context), + }, + ); + } + + #[cfg(not(target_family = "wasm"))] + fn log_llm_request_error( + &self, + request_id: Uuid, + error: impl std::fmt::Display, + recovery: &str, + ctx: &mut ModelContext, + ) { + let mut context = self.common_remote_log_context("llm_request_error", request_id); + context.insert( + "elapsed_ms".to_string(), + serde_json::json!(self.time_to_latest_event.num_milliseconds()), + ); + context.insert("recovery".to_string(), serde_json::json!(recovery)); + context.insert( + "error".to_string(), + serde_json::json!(remote_logging::sanitize_error(error)), + ); + remote_logging::log_model_event( + ctx, + RemoteLogRecord { + level: RemoteLogLevel::Warn, + message: format!("LLM request error: {recovery}"), + context: serde_json::Value::Object(context), + }, + ); + } + + #[cfg(not(target_family = "wasm"))] + fn log_raw_model_response( + &self, + request_id: Uuid, + payload_kind: &str, + raw_payload: impl AsRef, + ctx: &mut ModelContext, + ) { + let Some(raw_payload) = + remote_logging::raw_model_payload_context(ctx, raw_payload.as_ref()) + else { + return; + }; + let mut context = self.common_remote_log_context("raw_model_response", request_id); + context.insert("payload_kind".to_string(), serde_json::json!(payload_kind)); + context.insert("raw_payload".to_string(), raw_payload); + remote_logging::log_model_event( + ctx, + RemoteLogRecord { + level: RemoteLogLevel::Info, + message: "Raw model response".to_string(), + context: serde_json::Value::Object(context), + }, + ); + } + #[cfg(not(target_family = "wasm"))] fn resolve_acp_manager( backend: &crate::persistence::model::AcpConversationData, @@ -300,23 +554,23 @@ impl ResponseStream { use galaxy_acp::AcpManagerConfig; let settings = AISettings::as_ref(ctx); - let configured_agent_id = settings.acp_agent_id.value().trim(); + let provider = if backend.provider_id.is_empty() { + settings.legacy_acp_provider() + } else { + settings.enabled_acp_provider_by_id(&backend.provider_id) + } + .ok_or_else(|| { + "The ACP connection for this conversation is no longer configured in Settings. Add it again or start a new ACP conversation." + .to_string() + })?; + let configured_agent_id = provider.agent_id.trim(); let configured_agent_id = if configured_agent_id.is_empty() { "codex" } else { configured_agent_id }; - let launch = resolve_acp_launch( - configured_agent_id, - settings.acp_agent_command.value(), - settings.acp_agent_args.value(), - )?; - validate_acp_launch_identity( - backend, - configured_agent_id, - settings.acp_agent_command.value(), - &launch, - )?; + let launch = resolve_acp_launch(configured_agent_id, &provider.command, &provider.args)?; + validate_acp_launch_identity(backend, configured_agent_id, &provider.command, &launch)?; let config = AcpManagerConfig::new(launch); AcpRuntimeModel::handle(ctx).update(ctx, |runtime, _| runtime.manager(config)) } @@ -447,6 +701,7 @@ impl ResponseStream { let start_time = Local::now(); let request_id = Uuid::new_v4(); + let response_stream_id = ResponseStreamId(Uuid::new_v4().to_string()); let runtime_capabilities = match &agent_backend { AgentBackend::Provider => RuntimeCapabilities::provider(), AgentBackend::Acp(_) => RuntimeCapabilities::session_runtime(), @@ -455,9 +710,28 @@ impl ResponseStream { let acp_session_metadata = Arc::new(Mutex::new(AcpSessionMetadata::default())); #[cfg(not(target_family = "wasm"))] let acp_turn_control = Arc::new(Mutex::new(None)); + #[cfg(not(target_family = "wasm"))] + let remote_log_backend; + #[cfg(not(target_family = "wasm"))] + let remote_log_provider; match &agent_backend { AgentBackend::Provider => { let provider_config = Self::resolve_provider_config(params.model.as_str(), ctx); + #[cfg(not(target_family = "wasm"))] + { + remote_log_backend = "provider".to_string(); + remote_log_provider = Self::remote_log_provider_for_config(&provider_config); + Self::log_llm_request_started( + ctx, + &response_stream_id, + request_id, + ¶ms, + &ai_identifiers, + &remote_log_backend, + &remote_log_provider, + can_attempt_resume_on_error, + ); + } Self::spawn_provider_request( params.clone(), provider_config, @@ -467,6 +741,25 @@ impl ResponseStream { ); } AgentBackend::Acp(backend) => { + #[cfg(not(target_family = "wasm"))] + { + remote_log_backend = "acp".to_string(); + remote_log_provider = if backend.agent_id.is_empty() { + "acp".to_string() + } else { + format!("acp:{}", backend.agent_id) + }; + Self::log_llm_request_started( + ctx, + &response_stream_id, + request_id, + ¶ms, + &ai_identifiers, + &remote_log_backend, + &remote_log_provider, + can_attempt_resume_on_error, + ); + } #[cfg(not(target_family = "wasm"))] Self::spawn_acp_request( backend.clone(), @@ -500,7 +793,7 @@ impl ResponseStream { } } Self { - id: ResponseStreamId(Uuid::new_v4().to_string()), + id: response_stream_id, runtime_capabilities, #[cfg(not(target_family = "wasm"))] acp_session_metadata, @@ -515,6 +808,10 @@ impl ResponseStream { original_error: None, has_received_client_actions: false, ai_identifiers, + #[cfg(not(target_family = "wasm"))] + remote_log_backend, + #[cfg(not(target_family = "wasm"))] + remote_log_provider, can_attempt_resume_on_error, should_resume_conversation_after_stream_finished: false, stream_finished_received: false, @@ -640,6 +937,16 @@ impl ResponseStream { let request_id = Uuid::new_v4(); self.current_request_id = Some(request_id); + #[cfg(not(target_family = "wasm"))] + self.log_galaxy_decision( + request_id, + "retry_request", + serde_json::json!({ + "retry_count": self.retry_count, + "model_id": self.params.model.as_str(), + }), + ctx, + ); let params = self.params.clone(); let provider_config = Self::resolve_provider_config(params.model.as_str(), ctx); let _ = ctx.spawn( @@ -675,6 +982,18 @@ impl ResponseStream { } fn retry_with_coding_model(&mut self, ctx: &mut ModelContext) { + #[cfg(not(target_family = "wasm"))] + if let Some(request_id) = self.current_request_id { + self.log_galaxy_decision( + request_id, + "fallback_to_coding_model", + serde_json::json!({ + "from_model_id": self.params.model.as_str(), + "to_model_id": self.params.coding_model.as_str(), + }), + ctx, + ); + } self.coding_model_fallback_attempted = true; self.params.model = self.params.coding_model.clone(); self.retry(ctx); @@ -730,6 +1049,13 @@ impl ResponseStream { // terminally. (HTTP send failures don't take this path — they arrive as // in-stream error events.) let error = Arc::new(AIApiError::Other(anyhow!(e))); + #[cfg(not(target_family = "wasm"))] + self.log_llm_request_error( + request_id, + error.as_ref(), + "stream_creation_failed", + ctx, + ); self.error_event_emitted = true; self.report_request_failure(&error, NetworkStatus::as_ref(ctx).is_online()); ctx.emit(ResponseStreamEvent::ReceivedEvent(Consumable::new(Err( @@ -762,6 +1088,42 @@ impl ResponseStream { action.id, action.task_id ); + #[cfg(not(target_family = "wasm"))] + { + let mut context = + self.common_remote_log_context("llm_tool_proposed", request_id); + context.insert( + "action_id".to_string(), + serde_json::json!(action.id.to_string()), + ); + context.insert( + "task_id".to_string(), + serde_json::json!(action.task_id.to_string()), + ); + context.insert( + "tool_name".to_string(), + serde_json::json!(action_tool_name(action)), + ); + context.insert( + "requires_result".to_string(), + serde_json::json!(action.requires_result), + ); + remote_logging::log_model_event( + ctx, + RemoteLogRecord { + level: RemoteLogLevel::Info, + message: "LLM proposed tool".to_string(), + context: serde_json::Value::Object(context), + }, + ); + } + #[cfg(not(target_family = "wasm"))] + self.log_raw_model_response( + request_id, + "tool_proposed", + format!("{action:#?}"), + ctx, + ); ctx.emit(ResponseStreamEvent::ReceivedEvent(Consumable::new(event))); } Ok(api::StreamEvent::Response(response_event)) => { @@ -787,6 +1149,13 @@ impl ResponseStream { None => "None", }; log::info!("[bedrock-debug] ResponseStream emitting event type={event_type_name}"); + #[cfg(not(target_family = "wasm"))] + self.log_raw_model_response( + request_id, + event_type_name, + format!("{response_event:#?}"), + ctx, + ); if let Some(event_type) = &response_event.r#type { match event_type { warp_multi_agent_api::response_event::Type::Init(init_event) => { @@ -796,12 +1165,35 @@ impl ResponseStream { init_event.request_id.clone(), )); } - warp_multi_agent_api::response_event::Type::ClientActions(_) => { + warp_multi_agent_api::response_event::Type::ClientActions( + client_actions, + ) => { // Mark that we've received client actions self.has_received_client_actions = true; + #[cfg(not(target_family = "wasm"))] + { + let mut context = self.common_remote_log_context( + "llm_client_actions_received", + request_id, + ); + context.insert( + "action_count".to_string(), + serde_json::json!(client_actions.actions.len()), + ); + remote_logging::log_model_event( + ctx, + RemoteLogRecord { + level: RemoteLogLevel::Info, + message: "LLM client actions received".to_string(), + context: serde_json::Value::Object(context), + }, + ); + } } warp_multi_agent_api::response_event::Type::Finished(finished_event) => { self.stream_finished_received = true; + #[cfg(not(target_family = "wasm"))] + self.log_llm_response_finished(request_id, finished_event, ctx); // Emit retry success telemetry on successful completion if matches!( finished_event.reason, @@ -837,6 +1229,13 @@ impl ResponseStream { log::warn!( "Thinking model rate-limited; retrying with the profile coding model" ); + #[cfg(not(target_family = "wasm"))] + self.log_llm_request_error( + request_id, + e.as_ref(), + "fallback_to_coding_model", + ctx, + ); self.retry_with_coding_model(ctx); return; } @@ -855,6 +1254,8 @@ impl ResponseStream { self.retry_count + 1, MAX_RETRIES ); + #[cfg(not(target_family = "wasm"))] + self.log_llm_request_error(request_id, e.as_ref(), "retry_now", ctx); // Only emit error telemetry here if we're retrying. // Final errors that aren't being retried are emitted elsewhere. self.emit_retryable_agent_mode_error_telemetry(format!("{e:?}"), ctx); @@ -868,6 +1269,13 @@ impl ResponseStream { self.retry_count + 1, MAX_RETRIES ); + #[cfg(not(target_family = "wasm"))] + self.log_llm_request_error( + request_id, + e.as_ref(), + "retry_when_online", + ctx, + ); self.emit_retryable_agent_mode_error_telemetry(format!("{e:?}"), ctx); self.defer_retry_until_online(ctx); return; @@ -880,10 +1288,20 @@ impl ResponseStream { log::warn!( "MultiAgent request failed after client actions; resuming conversation after stream finishes - Error: {e:?}" ); + #[cfg(not(target_family = "wasm"))] + self.log_llm_request_error( + request_id, + e.as_ref(), + "resume_after_stream", + ctx, + ); // The resume spawn itself waits for connectivity. self.should_resume_conversation_after_stream_finished = true; } - RecoveryAction::Fail => {} + RecoveryAction::Fail => { + #[cfg(not(target_family = "wasm"))] + self.log_llm_request_error(request_id, e.as_ref(), "fail", ctx); + } } self.error_event_emitted = true; @@ -928,6 +1346,13 @@ impl ResponseStream { self.retry_count + 1, MAX_RETRIES ); + #[cfg(not(target_family = "wasm"))] + self.log_llm_request_error( + request_id, + unexpected_eof.as_ref(), + "retry_now", + ctx, + ); self.emit_retryable_agent_mode_error_telemetry( format!("{unexpected_eof:?}"), ctx, @@ -941,6 +1366,13 @@ impl ResponseStream { self.retry_count + 1, MAX_RETRIES ); + #[cfg(not(target_family = "wasm"))] + self.log_llm_request_error( + request_id, + unexpected_eof.as_ref(), + "retry_when_online", + ctx, + ); self.emit_retryable_agent_mode_error_telemetry( format!("{unexpected_eof:?}"), ctx, @@ -956,6 +1388,13 @@ impl ResponseStream { log::warn!( "MultiAgent request truncated after client actions; resuming conversation after stream finishes - Error: {unexpected_eof:?}" ); + #[cfg(not(target_family = "wasm"))] + self.log_llm_request_error( + request_id, + unexpected_eof.as_ref(), + "resume_after_stream", + ctx, + ); self.should_resume_conversation_after_stream_finished = true; self.error_event_emitted = true; self.report_request_failure(&unexpected_eof, is_online); @@ -964,6 +1403,8 @@ impl ResponseStream { )))); } RecoveryAction::Fail => { + #[cfg(not(target_family = "wasm"))] + self.log_llm_request_error(request_id, unexpected_eof.as_ref(), "fail", ctx); self.error_event_emitted = true; self.report_request_failure(&unexpected_eof, is_online); ctx.emit(ResponseStreamEvent::ReceivedEvent(Consumable::new(Err( @@ -1031,6 +1472,118 @@ impl ResponseStream { } } +#[cfg(not(target_family = "wasm"))] +fn raw_model_request_payload(params: &api::RequestParams) -> String { + let payload = serde_json::json!({ + "model_id": params.model.as_str(), + "coding_model_id": params.coding_model.as_str(), + "cli_agent_model_id": params.cli_agent_model.as_str(), + "computer_use_model_id": params.computer_use_model.as_str(), + "input": format!("{:#?}", params.input), + "tool_results": format!("{:#?}", params.tool_results), + "tasks": format!("{:#?}", params.tasks), + "message_history": serde_json::to_value(¶ms.message_history) + .unwrap_or_else(|_| serde_json::json!(format!("{:#?}", params.message_history))), + "progressive_summary": ¶ms.progressive_summary, + "tool_result_archive": serde_json::to_value(¶ms.tool_result_archive) + .unwrap_or_else(|_| serde_json::json!(format!("{:#?}", params.tool_result_archive))), + "global_rules": ¶ms.global_rules, + "mcp_context": format!("{:#?}", params.mcp_context), + "session": { + "is_remote": params.session_context.is_remote(), + }, + "features": { + "memory_enabled": params.is_memory_enabled, + "warp_drive_context_enabled": params.warp_drive_context_enabled, + "planning_enabled": params.planning_enabled, + "web_search_enabled": params.web_search_enabled, + "computer_use_enabled": params.computer_use_enabled, + "ask_user_question_enabled": params.ask_user_question_enabled, + "research_agent_enabled": params.research_agent_enabled, + "orchestration_enabled": params.orchestration_enabled, + }, + "autonomy_level": format!("{:?}", params.autonomy_level), + "isolation_level": format!("{:?}", params.isolation_level), + "supported_tools_override": format!("{:#?}", params.supported_tools_override), + "context_window_limit": params.context_window_limit, + "omitted_sensitive_fields": [ + "api_keys", + "custom_model_providers", + "custom_model_routers", + ], + }); + serde_json::to_string_pretty(&payload).unwrap_or_else(|_| format!("{payload:#?}")) +} + +#[cfg(not(target_family = "wasm"))] +fn action_tool_name(action: &crate::ai::agent::AIAgentAction) -> String { + action.tool_name.clone().unwrap_or_else(|| { + format!( + "{:?}", + crate::ai::agent::AIAgentActionTypeDiscriminants::from(&action.action) + ) + }) +} + +#[cfg(not(target_family = "wasm"))] +fn stream_finished_reason_name( + reason: &Option, +) -> &'static str { + match reason { + None => "done", + Some(response_event::stream_finished::Reason::Done(_)) => "done", + Some(response_event::stream_finished::Reason::MaxTokenLimit(_)) => "max_token_limit", + Some(response_event::stream_finished::Reason::Other(_)) => "other", + Some(response_event::stream_finished::Reason::ContextWindowExceeded(_)) => { + "context_window_exceeded" + } + Some(response_event::stream_finished::Reason::QuotaLimit(_)) => "quota_limit", + Some(response_event::stream_finished::Reason::LlmUnavailable(_)) => "llm_unavailable", + Some(response_event::stream_finished::Reason::InvalidApiKey(_)) => "invalid_api_key", + Some(response_event::stream_finished::Reason::InternalError(_)) => "internal_error", + } +} + +#[cfg(not(target_family = "wasm"))] +fn token_usage_context( + token_usage: &[response_event::stream_finished::TokenUsage], +) -> serde_json::Value { + let total_input: u64 = token_usage + .iter() + .map(|usage| u64::from(usage.total_input)) + .sum(); + let output: u64 = token_usage + .iter() + .map(|usage| u64::from(usage.output)) + .sum(); + let input_cache_read: u64 = token_usage + .iter() + .map(|usage| u64::from(usage.input_cache_read)) + .sum(); + let input_cache_write: u64 = token_usage + .iter() + .map(|usage| u64::from(usage.input_cache_write)) + .sum(); + let cost_in_cents: f32 = token_usage.iter().map(|usage| usage.cost_in_cents).sum(); + serde_json::json!({ + "total_input": total_input, + "output": output, + "input_cache_read": input_cache_read, + "input_cache_write": input_cache_write, + "cost_in_cents": cost_in_cents, + "models": token_usage.iter().map(|usage| { + serde_json::json!({ + "model_id": usage.model_id, + "total_input": usage.total_input, + "output": usage.output, + "input_cache_read": usage.input_cache_read, + "input_cache_write": usage.input_cache_write, + "cost_in_cents": usage.cost_in_cents, + }) + }).collect::>(), + }) +} + #[cfg(not(target_family = "wasm"))] fn is_interactive_remote_command(command: &str) -> bool { is_potential_remote_ssh_command(command) diff --git a/app/src/ai/blocklist/controller_tests.rs b/app/src/ai/blocklist/controller_tests.rs index 68f5ebaf..ac43bce3 100644 --- a/app/src/ai/blocklist/controller_tests.rs +++ b/app/src/ai/blocklist/controller_tests.rs @@ -64,6 +64,7 @@ fn acp_backend_model_identity_does_not_claim_a_provider_model() { assert_eq!(super::acp_backend_model_id(&AgentBackend::Provider), None); assert_eq!( super::acp_backend_model_id(&AgentBackend::Acp(AcpConversationData { + provider_id: String::new(), agent_id: " Codex ".to_owned(), launch_fingerprint: "launch-123".to_owned(), session_id: None, @@ -74,6 +75,19 @@ fn acp_backend_model_identity_does_not_claim_a_provider_model() { })), Some(LLMId::from("acp:codex:model=\"fast\"")) ); + assert_eq!( + super::acp_backend_model_id(&AgentBackend::Acp(AcpConversationData { + provider_id: "work".to_owned(), + agent_id: " Codex ".to_owned(), + launch_fingerprint: "launch-123".to_owned(), + session_id: None, + config_values: std::collections::BTreeMap::from([( + "model".to_owned(), + serde_json::json!("fast"), + )]), + })), + Some(LLMId::from("acp:work:codex:model=\"fast\"")) + ); } #[test] diff --git a/app/src/ai/blocklist/history_model.rs b/app/src/ai/blocklist/history_model.rs index 5bec2280..f367e4c8 100644 --- a/app/src/ai/blocklist/history_model.rs +++ b/app/src/ai/blocklist/history_model.rs @@ -1206,33 +1206,29 @@ impl BlocklistAIHistoryModel { return llm_preferences.agent_backend_for_active_model(Some(terminal_surface_id), ctx); } - let configured_agent_id = settings.acp_agent_id.value().trim(); - let agent_id = if configured_agent_id.is_empty() { + let providers = settings.enabled_acp_providers(); + let [provider] = providers.as_slice() else { + return AgentBackend::Provider; + }; + let agent_id = provider.agent_id.trim(); + let agent_id = if agent_id.is_empty() { "codex" } else { - configured_agent_id + agent_id }; #[cfg(not(target_family = "wasm"))] - let launch_fingerprint = acp_launch_fingerprint( - agent_id, - settings.acp_agent_command.value(), - settings.acp_agent_args.value(), - ); + let launch_fingerprint = + acp_launch_fingerprint(agent_id, &provider.command, &provider.args); #[cfg(target_family = "wasm")] let launch_fingerprint = String::new(); AgentBackend::Acp(AcpConversationData { + provider_id: provider.id.clone(), agent_id: agent_id.to_string(), launch_fingerprint, session_id: None, - config_values: settings - .acp_agents - .value() - .iter() - .find(|agent| agent.id.eq_ignore_ascii_case(agent_id)) - .map(|agent| { - crate::ai::acp::AcpRuntimeModel::current_config_values(&agent.config_options) - }) - .unwrap_or_default(), + config_values: crate::ai::acp::AcpRuntimeModel::current_config_values( + &provider.config_options, + ), }) } diff --git a/app/src/ai/blocklist/history_model_tests.rs b/app/src/ai/blocklist/history_model_tests.rs index 652ba6d4..e1372c27 100644 --- a/app/src/ai/blocklist/history_model_tests.rs +++ b/app/src/ai/blocklist/history_model_tests.rs @@ -78,6 +78,7 @@ fn acp_enabled_with_empty_command_selects_codex_backend() { assert_eq!( conversation.agent_backend(), &AgentBackend::Acp(AcpConversationData { + provider_id: "legacy".to_string(), agent_id: "codex".to_string(), launch_fingerprint: crate::ai::acp::acp_launch_fingerprint("codex", "", &[]), session_id: None, diff --git a/app/src/ai/chatgpt_auth.rs b/app/src/ai/chatgpt_auth.rs index ec2867e0..1fe1dcae 100644 --- a/app/src/ai/chatgpt_auth.rs +++ b/app/src/ai/chatgpt_auth.rs @@ -183,9 +183,15 @@ fn chatgpt_redirect_uri() -> String { format!("{}://chatgpt/oauth2callback", ChannelState::url_scheme()) } +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ChatGPTAuthCredentials { + pub(crate) access_token: String, + pub(crate) account_id: Option, +} + /// Attempts to read tokens from `~/.codex/auth.json` and write them to Rig's auth file. /// Returns `Ok(())` if credentials were found and successfully imported. -fn import_codex_credentials() -> Result<(), String> { +pub(crate) fn import_codex_credentials() -> Result<(), String> { let codex_path = codex_auth_file_path().ok_or("Cannot determine codex auth path")?; let bytes = std::fs::read(&codex_path).map_err(|e| format!("{e}"))?; let doc: serde_json::Value = serde_json::from_slice(&bytes).map_err(|e| format!("{e}"))?; @@ -236,6 +242,55 @@ fn import_codex_credentials() -> Result<(), String> { write_auth_file(&record) } +pub(crate) fn load_or_import_auth_credentials() -> Result { + load_auth_credentials().or_else(|load_error| { + import_codex_credentials().map_err(|import_error| { + format!( + "Could not load ChatGPT credentials ({load_error}) or import Codex credentials ({import_error})." + ) + })?; + load_auth_credentials() + }) +} + +fn load_auth_credentials() -> Result { + let path = auth_file_path().ok_or("Cannot determine ChatGPT auth file path")?; + let bytes = std::fs::read(&path) + .map_err(|error| format!("Failed to read {}: {error}", path.display()))?; + let record: AuthRecord = serde_json::from_slice(&bytes) + .map_err(|error| format!("Failed to parse {}: {error}", path.display()))?; + let access_token = record + .access_token + .as_deref() + .filter(|token| !token.trim().is_empty()) + .ok_or("ChatGPT auth file does not contain an access token")?; + + let expires_at = record + .expires_at + .or_else(|| extract_expiration_timestamp(access_token)); + if let Some(expires_at) = expires_at { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_secs() as i64) + .unwrap_or(0); + if now >= expires_at - 60 { + return Err("ChatGPT access token is expired".to_string()); + } + } + + let account_id = record + .account_id + .clone() + .filter(|account_id| !account_id.trim().is_empty()) + .or_else(|| extract_account_id(record.id_token.as_deref())) + .or_else(|| extract_account_id(Some(access_token))); + + Ok(ChatGPTAuthCredentials { + access_token: access_token.to_string(), + account_id, + }) +} + fn codex_auth_file_path() -> Option { if let Some(codex_home) = std::env::var_os("CODEX_HOME") { return Some(std::path::PathBuf::from(codex_home).join("auth.json")); @@ -382,7 +437,7 @@ struct TokenResponse { id_token: Option, } -#[derive(serde::Serialize)] +#[derive(serde::Deserialize, serde::Serialize)] struct AuthRecord { access_token: Option, refresh_token: Option, diff --git a/app/src/ai/llms.rs b/app/src/ai/llms.rs index 09855648..a6915490 100644 --- a/app/src/ai/llms.rs +++ b/app/src/ai/llms.rs @@ -2,6 +2,7 @@ use std::collections::{BTreeMap, HashMap, HashSet}; use std::sync::{Arc, OnceLock}; +use std::time::Duration; use ai::api_keys::ApiKeyManager; pub use ai::LLMId; @@ -13,6 +14,7 @@ use galaxy_agent_rig::{ use galaxy_core::features::FeatureFlag; use galaxy_core::ui::icons::Icon; use galaxy_core::user_preferences::GetUserPreferences; +use galaxyui::r#async::Timer; use galaxyui::{AppContext, Entity, EntityId, ModelContext, SingletonEntity}; use parking_lot::FairMutex; use serde::{de, Deserialize, Serialize}; @@ -21,7 +23,7 @@ use warp_multi_agent_api as api; use super::custom_model_routers::{self, CustomModelRouter, ModelConfigError}; use super::execution_profiles::profiles::AIExecutionProfilesModel; -use crate::ai::acp::{acp_launch_fingerprint, acp_selection_identity}; +use crate::ai::acp::{acp_launch_fingerprint, acp_provider_selection_identity}; use crate::auth::auth_manager::{AuthManager, AuthManagerEvent}; use crate::auth::AuthStateProvider; use crate::network::{NetworkStatus, NetworkStatusEvent, NetworkStatusKind}; @@ -29,8 +31,8 @@ use crate::network::{NetworkStatus, NetworkStatusEvent, NetworkStatusKind}; use crate::persistence::model::{AcpConversationData, AgentBackend}; use crate::server::server_api::ServerApiProvider; use crate::settings::{ - AcpConfigValueSettings, BedrockModelConfig, OpenAIModelConfig, OpenAIProviderConfig, - OpenAIProviderKind, + AcpConfigValueSettings, AcpProviderConfig, BedrockModelConfig, OpenAIModelConfig, + OpenAIProviderConfig, OpenAIProviderKind, }; use crate::user_config::{WarpConfig, WarpConfigUpdateEvent}; use crate::workspaces::user_workspaces::{UserWorkspaces, UserWorkspacesEvent}; @@ -57,6 +59,10 @@ pub fn should_show_bedrock_icon_for_model(llm: &LLMInfo, app: &AppContext) -> bo /// but was migrated to store a full [`ModelsByFeature`]. pub const MODELS_BY_FEATURE_CACHE_KEY: &str = "AvailableLLMs"; const CUSTOM_ENDPOINT_USAGE_FALLBACK_LABEL: &str = "Custom endpoint"; +const CHATGPT_CODEX_MODELS_URL: &str = "https://chatgpt.com/backend-api/codex/models"; +const CODEX_LATEST_RELEASE_URL: &str = "https://api.github.com/repos/openai/codex/releases/latest"; +const CHATGPT_SUBSCRIPTION_MODELS_REFRESH_INTERVAL: Duration = Duration::from_secs(60 * 60 * 24); +const DEFAULT_DISCOVERED_MODEL_CONTEXT_SIZE: u32 = 200_000; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct LLMUsageMetadata { @@ -591,13 +597,17 @@ pub struct LLMPreferences { #[cfg(not(target_family = "wasm"))] fetched_openai_models: Vec, #[cfg(not(target_family = "wasm"))] + chatgpt_subscription_models_refresh_in_flight: bool, + #[cfg(not(target_family = "wasm"))] acp_selections: HashMap, } #[cfg(not(target_family = "wasm"))] #[derive(Clone, Debug, PartialEq)] pub(crate) struct AcpModelSelection { + pub(crate) provider_id: String, pub(crate) agent_id: String, + pub(crate) launch_fingerprint: String, pub(crate) config_values: BTreeMap, } @@ -611,6 +621,7 @@ impl LLMPreferences { } = event { me.refresh_authed_models(ctx); + me.refresh_chatgpt_subscription_models(ctx); } }); @@ -621,6 +632,7 @@ impl LLMPreferences { ctx.subscribe_to_model(&AuthManager::handle(ctx), |me, _, event, ctx| { if let AuthManagerEvent::AuthComplete = event { me.refresh_authed_models(ctx); + me.refresh_chatgpt_subscription_models(ctx); } }); @@ -664,6 +676,7 @@ impl LLMPreferences { | AISettingsChangedEvent::OpenAIApiKey { .. } | AISettingsChangedEvent::OpenAIModels { .. } | AISettingsChangedEvent::OpenAIProviders { .. } + | AISettingsChangedEvent::AcpProviders { .. } | AISettingsChangedEvent::AcpAgents { .. } | AISettingsChangedEvent::AcpAgentId { .. } | AISettingsChangedEvent::BedrockModels { .. } @@ -704,6 +717,8 @@ impl LLMPreferences { #[cfg(not(target_family = "wasm"))] fetched_openai_models: Vec::new(), #[cfg(not(target_family = "wasm"))] + chatgpt_subscription_models_refresh_in_flight: false, + #[cfg(not(target_family = "wasm"))] acp_selections: HashMap::new(), }; @@ -727,6 +742,8 @@ impl LLMPreferences { me.inject_openai_models(ctx); me.ensure_default_model_present(); me.fetch_openai_models_from_endpoint(ctx); + me.refresh_chatgpt_subscription_models(ctx); + me.schedule_chatgpt_subscription_model_refresh(ctx); } me @@ -742,15 +759,10 @@ impl LLMPreferences { continue; } - for default_model in &default_chatgpt_models { - if !provider - .models - .iter() - .any(|model| model.model_id == default_model.model_id) - { - provider.models.push(default_model.clone()); - providers_changed = true; - } + if provider.models.is_empty() { + provider.models = default_chatgpt_models.clone(); + providers_changed = true; + continue; } for model in &mut provider.models { @@ -1216,43 +1228,41 @@ impl LLMPreferences { } self.acp_selections.clear(); let settings = AISettings::as_ref(ctx); - if !*settings.acp_enabled.value() { + let providers = settings.enabled_acp_providers(); + if providers.is_empty() { return; } - let configured_agent_id = settings.acp_agent_id.value().trim(); - let configured_agent_id = if configured_agent_id.is_empty() { + let bedrock_enabled = *settings.bedrock_enabled.value(); + for provider in providers { + self.inject_acp_provider_models(&provider, bedrock_enabled); + } + } + + #[cfg(not(target_family = "wasm"))] + fn inject_acp_provider_models(&mut self, provider: &AcpProviderConfig, bedrock_enabled: bool) { + let agent_id = provider.agent_id.trim(); + let agent_id = if agent_id.is_empty() { "codex" } else { - configured_agent_id + agent_id }; - let bedrock_enabled = *settings.bedrock_enabled.value(); - let configured_agent = settings - .acp_agents - .value() - .iter() - .find(|agent| agent.id.eq_ignore_ascii_case(configured_agent_id)); - let Some(agent) = configured_agent else { - let display_name = acp_agent_display_name(configured_agent_id); - self.push_acp_model( - configured_agent_id, - &display_name, - &display_name, - BTreeMap::new(), - None, - ); + let agent_name = acp_agent_display_name(agent_id); + if provider.config_options.is_empty() { + self.push_acp_model(provider, &agent_name, &agent_name, BTreeMap::new(), None); return; - }; - let model_option = agent + } + + let model_option = provider .config_options .iter() .find(|option| option.category.as_deref() == Some("model")); let Some(model_option) = model_option else { let selection = - crate::ai::acp::AcpRuntimeModel::current_config_values(&agent.config_options); - self.push_acp_model(&agent.id, &agent.name, &agent.name, selection, None); + crate::ai::acp::AcpRuntimeModel::current_config_values(&provider.config_options); + self.push_acp_model(provider, &agent_name, &agent_name, selection, None); return; }; - let reasoning_option = agent + let reasoning_option = provider .config_options .iter() .find(|option| option.category.as_deref() == Some("thought_level")); @@ -1262,7 +1272,7 @@ impl LLMPreferences { .filter(|value| acp_model_is_enabled(&value.value, bedrock_enabled)) { let mut selection = - crate::ai::acp::AcpRuntimeModel::current_config_values(&agent.config_options); + crate::ai::acp::AcpRuntimeModel::current_config_values(&provider.config_options); selection.insert(model_option.id.clone(), value.value.clone()); if let Some(reasoning_option) = reasoning_option.filter(|option| !option.options.is_empty()) @@ -1271,7 +1281,7 @@ impl LLMPreferences { let mut selection = selection.clone(); selection.insert(reasoning_option.id.clone(), reasoning.value.clone()); self.push_acp_model( - &agent.id, + provider, &value.name, &value.name, selection, @@ -1279,7 +1289,7 @@ impl LLMPreferences { ); } } else { - self.push_acp_model(&agent.id, &value.name, &value.name, selection, None); + self.push_acp_model(provider, &value.name, &value.name, selection, None); } } } @@ -1287,7 +1297,7 @@ impl LLMPreferences { #[cfg(not(target_family = "wasm"))] fn push_acp_model( &mut self, - agent_id: &str, + provider: &AcpProviderConfig, display_name: &str, base_model_name: &str, selection: BTreeMap, @@ -1297,12 +1307,30 @@ impl LLMPreferences { || display_name.to_owned(), |reasoning| format!("{display_name} ({})", reasoning.name), ); - let id = acp_selection_identity(agent_id, &selection); + let provider_name = provider.display_name(); + let display_name = if display_name.eq_ignore_ascii_case(&provider_name) { + display_name + } else { + format!("{display_name} · {provider_name}") + }; + let agent_id = provider.agent_id.trim(); + let agent_id = if agent_id.is_empty() { + "codex" + } else { + agent_id + }; + let id = acp_provider_selection_identity(&provider.id, agent_id, &selection); let llm_id = LLMId::from(id.as_str()); self.acp_selections.insert( llm_id.clone(), AcpModelSelection { + provider_id: provider.id.clone(), agent_id: agent_id.to_owned(), + launch_fingerprint: acp_launch_fingerprint( + agent_id, + &provider.command, + &provider.args, + ), config_values: selection, }, ); @@ -1315,7 +1343,7 @@ impl LLMPreferences { request_multiplier: 1, credit_multiplier: None, }, - description: None, + description: Some("ACP".to_string()), disable_reason: None, vision_supported: false, spec: None, @@ -1362,12 +1390,9 @@ impl LLMPreferences { let active_model = self.get_active_base_model(ctx, terminal_view_id); if let Some(selection) = self.acp_runtime_selection_for_model(&active_model.id) { return AgentBackend::Acp(AcpConversationData { + provider_id: selection.provider_id.clone(), agent_id: selection.agent_id.clone(), - launch_fingerprint: acp_launch_fingerprint( - &selection.agent_id, - settings.acp_agent_command.value(), - settings.acp_agent_args.value(), - ), + launch_fingerprint: selection.launch_fingerprint.clone(), session_id: None, config_values: selection.config_values.clone(), }); @@ -1381,39 +1406,32 @@ impl LLMPreferences { // model option (and for agents that do not expose model selection at // all). A discovered model catalog with no enabled entries must not // fall back to its disabled current model, though. - let configured_agent_id = settings.acp_agent_id.value().trim(); - let agent_id = if configured_agent_id.is_empty() { - "codex" - } else { - configured_agent_id + let providers = settings.enabled_acp_providers(); + let [provider] = providers.as_slice() else { + return AgentBackend::Provider; }; - let configured_agent = settings - .acp_agents - .value() + if provider + .config_options .iter() - .find(|agent| agent.id.eq_ignore_ascii_case(agent_id)); - if configured_agent.is_some_and(|agent| { - agent - .config_options - .iter() - .any(|option| option.category.as_deref() == Some("model")) - }) { + .any(|option| option.category.as_deref() == Some("model")) + { return AgentBackend::Provider; } + let agent_id = provider.agent_id.trim(); + let agent_id = if agent_id.is_empty() { + "codex" + } else { + agent_id + }; AgentBackend::Acp(AcpConversationData { + provider_id: provider.id.clone(), agent_id: agent_id.to_owned(), - launch_fingerprint: acp_launch_fingerprint( - agent_id, - settings.acp_agent_command.value(), - settings.acp_agent_args.value(), - ), + launch_fingerprint: acp_launch_fingerprint(agent_id, &provider.command, &provider.args), session_id: None, - config_values: configured_agent - .map(|agent| { - crate::ai::acp::AcpRuntimeModel::current_config_values(&agent.config_options) - }) - .unwrap_or_default(), + config_values: crate::ai::acp::AcpRuntimeModel::current_config_values( + &provider.config_options, + ), }) } @@ -1537,17 +1555,32 @@ impl LLMPreferences { else { return; }; - if provider.base_url.trim().is_empty() { + if provider.kind != OpenAIProviderKind::ChatGPTSubscription + && provider.base_url.trim().is_empty() + { return; } let provider_kind = provider.kind; let requested_base_url = provider.base_url; + let requested_provider_kind = provider_kind; let api_key = provider.api_key.filter(|key| !key.is_empty()); let request_base_url = requested_base_url.clone(); let _ = ctx.spawn( async move { + if provider_kind == OpenAIProviderKind::ChatGPTSubscription { + return match Self::discover_chatgpt_subscription_models().await { + Ok(models) => models, + Err(error) => { + log::warn!( + "[chatgpt/models] Failed to discover ChatGPT subscription models: {error}" + ); + Vec::new() + } + }; + } + let base = request_base_url.trim_end_matches('/'); let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(10)) @@ -1577,12 +1610,20 @@ impl LLMPreferences { // Do not apply a response to an entry that was edited or // reordered while its discovery request was in flight. - if provider.base_url != requested_base_url { + if provider.kind != requested_provider_kind + || provider.base_url != requested_base_url + { return; } - provider.models = - merge_discovered_provider_models(&provider.models, discovered_models); + provider.models = if provider.kind == OpenAIProviderKind::ChatGPTSubscription { + merge_discovered_chatgpt_subscription_models( + &provider.models, + discovered_models, + ) + } else { + merge_discovered_provider_models(&provider.models, discovered_models) + }; if let Err(err) = settings.openai_providers.set_value(providers, ctx) { report_error!(err.context("Failed to persist discovered provider models")); } @@ -1599,6 +1640,10 @@ impl LLMPreferences { pub(crate) async fn discover_openai_provider_models( provider: OpenAIProviderConfig, ) -> Result, String> { + if provider.kind == OpenAIProviderKind::ChatGPTSubscription { + return Self::discover_chatgpt_subscription_models().await; + } + let native_models = match provider.kind { OpenAIProviderKind::Anthropic => { let api_key = provider @@ -1637,9 +1682,10 @@ impl LLMPreferences { )?; Some(vertex_ai_model_catalog()) } - OpenAIProviderKind::OpenAI - | OpenAIProviderKind::LiteLLM - | OpenAIProviderKind::ChatGPTSubscription => None, + OpenAIProviderKind::OpenAI | OpenAIProviderKind::LiteLLM => None, + OpenAIProviderKind::ChatGPTSubscription => { + unreachable!("ChatGPT subscription discovery is handled before native discovery") + } }; if let Some(models) = native_models { @@ -1684,6 +1730,93 @@ impl LLMPreferences { Ok(models) } + #[cfg(not(target_family = "wasm"))] + fn schedule_chatgpt_subscription_model_refresh(&self, ctx: &mut ModelContext) { + let _ = ctx.spawn( + async move { + Timer::after(CHATGPT_SUBSCRIPTION_MODELS_REFRESH_INTERVAL).await; + }, + |me, _, ctx| { + me.refresh_chatgpt_subscription_models(ctx); + me.schedule_chatgpt_subscription_model_refresh(ctx); + }, + ); + } + + #[cfg(not(target_family = "wasm"))] + fn refresh_chatgpt_subscription_models(&mut self, ctx: &mut ModelContext) { + if self.chatgpt_subscription_models_refresh_in_flight { + return; + } + + let settings = AISettings::as_ref(ctx); + if !*settings.openai_enabled.value() + || !settings.openai_providers.value().iter().any(|provider| { + provider.enabled && provider.kind == OpenAIProviderKind::ChatGPTSubscription + }) + { + return; + } + + self.chatgpt_subscription_models_refresh_in_flight = true; + let _ = ctx.spawn( + async { Self::discover_chatgpt_subscription_models().await }, + |me, result, ctx| { + me.chatgpt_subscription_models_refresh_in_flight = false; + let discovered_models = match result { + Ok(models) => models, + Err(error) => { + log::warn!( + "[chatgpt/models] Failed to refresh ChatGPT subscription models: {error}" + ); + return; + } + }; + + if discovered_models.is_empty() { + return; + } + + AISettings::handle(ctx).update(ctx, |settings, ctx| { + let mut providers = settings.openai_providers.value().clone(); + let mut changed = false; + for provider in &mut providers { + if provider.kind != OpenAIProviderKind::ChatGPTSubscription { + continue; + } + provider.models = merge_discovered_chatgpt_subscription_models( + &provider.models, + discovered_models.clone(), + ); + changed = true; + } + if changed { + if let Err(err) = settings.openai_providers.set_value(providers, ctx) { + report_error!( + err.context("Failed to persist ChatGPT subscription models") + ); + } + } + }); + + me.inject_openai_models(ctx); + me.ensure_default_model_present(); + ctx.emit(LLMPreferencesEvent::UpdatedAvailableLLMs); + }, + ); + } + + #[cfg(not(target_family = "wasm"))] + async fn discover_chatgpt_subscription_models() -> Result, String> { + let credentials = crate::ai::chatgpt_auth::load_or_import_auth_credentials()?; + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .build() + .map_err(|error| format!("Could not create the ChatGPT model client: {error}"))?; + let client_version = fetch_latest_codex_client_version(&client).await?; + fetch_from_chatgpt_codex_models(&client_version, credentials, &client).await + } + #[cfg(not(target_family = "wasm"))] fn rig_models_to_openai_models(models: Vec) -> Vec { models @@ -2539,6 +2672,44 @@ pub(crate) fn merge_discovered_provider_models( merged } +/// Merges ChatGPT subscription model metadata as a backend-owned catalog. +/// +/// Unlike generic OpenAI-compatible providers, ChatGPT subscription models come +/// from Codex's first-party model catalog. Models omitted from a successful +/// refresh should stop appearing in Galaxy unless they are rediscovered later. +#[cfg(not(target_family = "wasm"))] +pub(crate) fn merge_discovered_chatgpt_subscription_models( + existing_models: &[OpenAIModelConfig], + discovered_models: Vec, +) -> Vec { + let mut merged = Vec::with_capacity(discovered_models.len()); + let mut discovered_ids = HashSet::new(); + + for mut discovered in discovered_models { + if !discovered_ids.insert(discovered.model_id.clone()) { + continue; + } + + if let Some(existing) = existing_models + .iter() + .find(|model| model.model_id == discovered.model_id) + { + discovered.enabled = existing.enabled; + discovered.use_rig = existing.use_rig; + if existing.supports_system_messages.is_some() { + discovered.supports_system_messages = existing.supports_system_messages; + } + for (key, value) in &existing.capability_overrides { + discovered.capability_overrides.insert(key.clone(), *value); + } + } + + merged.push(discovered); + } + + merged +} + #[cfg(not(target_family = "wasm"))] fn openai_model_variant_id(model_id: &str, reasoning_effort: &str) -> String { format!("{model_id}::reasoning::{reasoning_effort}") @@ -2555,6 +2726,191 @@ fn openai_model_context_window(model: &OpenAIModelConfig) -> LLMContextWindow { } } +#[cfg(not(target_family = "wasm"))] +fn u32_from_json_any(value: &serde_json::Value, keys: &[&str]) -> Option { + keys.iter() + .find_map(|key| value[*key].as_u64()) + .and_then(|value| u32::try_from(value).ok()) +} + +#[cfg(not(target_family = "wasm"))] +fn normalize_codex_release_version(version: &str) -> Option { + let version = version.trim(); + let version = version + .strip_prefix("rust-v") + .or_else(|| version.strip_prefix('v')) + .unwrap_or(version); + if version.is_empty() + || !version + .chars() + .next() + .is_some_and(|first| first.is_ascii_digit()) + || !version.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '+') + }) + { + return None; + } + Some(version.to_string()) +} + +#[cfg(not(target_family = "wasm"))] +fn codex_client_version_from_release_json(body: &serde_json::Value) -> Option { + body["tag_name"] + .as_str() + .and_then(normalize_codex_release_version) + .or_else(|| { + body["name"] + .as_str() + .and_then(normalize_codex_release_version) + }) +} + +#[cfg(not(target_family = "wasm"))] +async fn fetch_latest_codex_client_version(client: &reqwest::Client) -> Result { + let response = client + .get(CODEX_LATEST_RELEASE_URL) + .header(reqwest::header::USER_AGENT, "Galaxy") + .send() + .await + .map_err(|error| format!("Could not fetch the latest Codex release: {error}"))?; + + if !response.status().is_success() { + return Err(format!( + "Could not fetch the latest Codex release: HTTP {}", + response.status() + )); + } + + let body: serde_json::Value = response + .json() + .await + .map_err(|error| format!("Could not parse the latest Codex release: {error}"))?; + codex_client_version_from_release_json(&body) + .ok_or_else(|| "The latest Codex release did not include a usable version.".to_string()) +} + +#[cfg(not(target_family = "wasm"))] +async fn fetch_from_chatgpt_codex_models( + client_version: &str, + credentials: crate::ai::chatgpt_auth::ChatGPTAuthCredentials, + client: &reqwest::Client, +) -> Result, String> { + let mut request = client + .get(CHATGPT_CODEX_MODELS_URL) + .query(&[("client_version", client_version)]) + .header( + reqwest::header::AUTHORIZATION, + format!("Bearer {}", credentials.access_token), + ) + .header(reqwest::header::ACCEPT, "application/json") + .header(reqwest::header::USER_AGENT, "Galaxy"); + if let Some(account_id) = credentials.account_id { + request = request.header("ChatGPT-Account-ID", account_id); + } + + let response = request + .send() + .await + .map_err(|error| format!("Could not fetch ChatGPT subscription models: {error}"))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(format!( + "ChatGPT model discovery failed: HTTP {status} {}", + body.chars().take(500).collect::() + )); + } + + let body: serde_json::Value = response + .json() + .await + .map_err(|error| format!("Could not parse ChatGPT subscription models: {error}"))?; + let models = chatgpt_models_from_codex_response(&body); + if models.is_empty() { + return Err("ChatGPT model discovery returned no visible models.".to_string()); + } + log::info!( + "[chatgpt/models] Fetched {} model(s) from Codex models endpoint using client_version={client_version}", + models.len() + ); + Ok(models) +} + +#[cfg(not(target_family = "wasm"))] +fn chatgpt_models_from_codex_response(body: &serde_json::Value) -> Vec { + let Some(models) = body["models"].as_array() else { + return Vec::new(); + }; + + models + .iter() + .filter_map(|model| { + if model["visibility"].as_str() != Some("list") { + return None; + } + + let model_id = model["slug"].as_str()?.trim(); + if model_id.is_empty() { + return None; + } + + let context_size = u32_from_json_any(model, &["context_window", "max_context_window"]) + .unwrap_or(DEFAULT_DISCOVERED_MODEL_CONTEXT_SIZE); + let effective_context_percent = model["effective_context_window_percent"] + .as_u64() + .and_then(|value| u32::try_from(value).ok()) + .unwrap_or(100); + let max_input_tokens = Some( + context_size + .checked_mul(effective_context_percent) + .map(|tokens| tokens / 100) + .unwrap_or(context_size), + ); + + let vision_supported = model["input_modalities"] + .as_array() + .map(|modalities| { + modalities + .iter() + .any(|modality| modality.as_str() == Some("image")) + }) + .unwrap_or(true); + let reasoning_efforts = model["supported_reasoning_levels"] + .as_array() + .map(|levels| { + levels + .iter() + .filter_map(|level| level["effort"].as_str()) + .filter(|effort| !effort.trim().is_empty()) + .map(str::to_string) + .collect::>() + }) + .unwrap_or_default(); + + Some(OpenAIModelConfig { + model_id: model_id.to_string(), + display_name: model["display_name"] + .as_str() + .filter(|display_name| !display_name.trim().is_empty()) + .unwrap_or(model_id) + .to_string(), + vision_supported, + context_size, + max_input_tokens, + max_output_tokens: None, + provider: Some("openai".to_string()), + use_rig: true, + supports_system_messages: Some(true), + capability_overrides: std::collections::HashMap::new(), + reasoning_efforts, + enabled: true, + }) + }) + .collect() +} + /// Fetches model metadata from LiteLLM's `/model/info` endpoint which returns rich /// metadata including accurate context window sizes, output token limits, and /// capability flags (vision, function calling). diff --git a/app/src/ai/llms_tests.rs b/app/src/ai/llms_tests.rs index 5976d3ad..9afbf049 100644 --- a/app/src/ai/llms_tests.rs +++ b/app/src/ai/llms_tests.rs @@ -11,7 +11,8 @@ use crate::server::cloud_objects::update_manager::UpdateManager; use crate::server::server_api::ServerApiProvider; use crate::server::sync_queue::SyncQueue; use crate::settings::{ - AcpAgentSettings, AcpConfigOptionSettings, AcpConfigValueSettings, OpenAIModelConfig, + AcpAgentSettings, AcpConfigOptionSettings, AcpConfigValueSettings, AcpProviderConfig, + OpenAIModelConfig, }; use crate::test_util::settings::initialize_settings_for_tests; use crate::workspaces::team_tester::TeamTesterStatus; @@ -210,6 +211,7 @@ fn empty_preferences() -> LLMPreferences { custom_model_routers: Vec::new(), openai_provider_routing: HashMap::new(), fetched_openai_models: Vec::new(), + chatgpt_subscription_models_refresh_in_flight: false, acp_selections: HashMap::new(), } } @@ -299,6 +301,7 @@ fn acp_models_are_injected_only_while_acp_is_enabled() { custom_model_routers: Vec::new(), openai_provider_routing: HashMap::new(), fetched_openai_models: Vec::new(), + chatgpt_subscription_models_refresh_in_flight: false, acp_selections: HashMap::new(), }; app.read(|ctx| preferences.inject_acp_models(ctx)); @@ -452,7 +455,7 @@ fn acp_models_expand_reasoning_levels_for_only_the_configured_agent() { .iter() .map(|model| model.display_name.as_str()) .collect::>(), - HashSet::from(["GPT Test (High)", "GPT Test (Xhigh)"]) + HashSet::from(["GPT Test (High) · OpenCode", "GPT Test (Xhigh) · OpenCode"]) ); assert!(models.iter().all(|model| { model.provider == LLMProvider::Acp && !model.display_name.contains("Read-only") @@ -464,6 +467,82 @@ fn acp_models_expand_reasoning_levels_for_only_the_configured_agent() { }); } +#[test] +fn acp_models_are_scoped_to_each_configured_provider() { + App::test((), |mut app| async move { + initialize_settings_for_tests(&mut app); + let config_options = vec![acp_select_option( + "model", + "model", + "gpt-test", + &[("gpt-test", "GPT Test")], + )]; + AISettings::handle(&app).update(&mut app, |settings, ctx| { + settings + .acp_enabled + .set_value(true, ctx) + .expect("ACP setting should update"); + settings + .bedrock_enabled + .set_value(false, ctx) + .expect("Bedrock setting should update"); + settings + .acp_providers + .set_value( + vec![ + AcpProviderConfig { + id: "work".to_owned(), + enabled: true, + name: "Codex Work".to_owned(), + agent_id: "codex".to_owned(), + command: String::new(), + args: Vec::new(), + config_options: config_options.clone(), + }, + AcpProviderConfig { + id: "personal".to_owned(), + enabled: true, + name: "Codex Personal".to_owned(), + agent_id: "codex".to_owned(), + command: String::new(), + args: Vec::new(), + config_options, + }, + ], + ctx, + ) + .expect("ACP providers should update"); + }); + + let mut preferences = empty_preferences(); + app.read(|ctx| preferences.inject_acp_models(ctx)); + + let models = preferences + .models_by_feature + .agent_mode + .choices + .iter() + .filter(|model| model.provider == LLMProvider::Acp) + .collect::>(); + assert_eq!(models.len(), 2); + assert_eq!( + models + .iter() + .map(|model| model.display_name.as_str()) + .collect::>(), + HashSet::from(["GPT Test · Codex Work", "GPT Test · Codex Personal"]) + ); + assert_eq!( + preferences + .acp_selections + .values() + .map(|selection| selection.provider_id.as_str()) + .collect::>(), + HashSet::from(["work", "personal"]) + ); + }); +} + #[test] fn acp_bedrock_models_are_hidden_while_bedrock_is_disabled() { App::test((), |mut app| async move { @@ -510,7 +589,7 @@ fn acp_bedrock_models_are_hidden_while_bedrock_is_disabled() { assert_eq!(preferences.models_by_feature.agent_mode.choices.len(), 1); assert_eq!( preferences.models_by_feature.agent_mode.choices[0].display_name, - "GPT Test" + "GPT Test · OpenCode" ); AISettings::handle(&app).update(&mut app, |settings, ctx| { @@ -578,9 +657,6 @@ fn chatgpt_reasoning_modes_route_with_catalog_context_metadata() { let gpt_56_sol = configured_model("gpt-5.6-sol"); assert_eq!(gpt_56_sol.context_size, 272_000); assert_eq!(gpt_56_sol.max_input_tokens, Some(258_400)); - let codex_spark = configured_model("gpt-5.3-codex-spark"); - assert_eq!(codex_spark.context_size, 128_000); - assert_eq!(codex_spark.max_input_tokens, Some(121_600)); let uncached_model = configured_model("gpt-5.4-pro"); assert_eq!(uncached_model.context_size, 200_000); assert_eq!(uncached_model.max_input_tokens, None); @@ -648,12 +724,12 @@ fn chatgpt_reasoning_modes_route_with_catalog_context_metadata() { assert_eq!(ultra_routing.reasoning_effort.as_deref(), Some("ultra")); assert_eq!(ultra_routing.max_input_tokens, Some(258_400)); - let spark_id = "gpt-5.3-codex-spark"; - assert_fixed_context(spark_id, 121_600); - let spark_routing = preferences - .openai_client_config_for_model(spark_id) - .expect("GPT-5.3 Codex Spark should have a routing entry"); - assert_eq!(spark_routing.max_input_tokens, Some(121_600)); + let uncached_id = "gpt-5.4-pro"; + assert_fixed_context(uncached_id, 200_000); + let uncached_routing = preferences + .openai_client_config_for_model(uncached_id) + .expect("GPT-5.4 Pro should have a routing entry"); + assert_eq!(uncached_routing.max_input_tokens, Some(200_000)); }); } @@ -670,3 +746,101 @@ fn provider_discovery_enables_rig_for_new_models_and_keeps_manual_models() { assert_eq!(merged[0].supports_system_messages, Some(false)); assert_eq!(merged[1].model_id, "manual-model"); } + +#[test] +fn codex_release_json_yields_client_version() { + let release = serde_json::json!({ + "tag_name": "rust-v0.147.0", + "name": "0.147.0" + }); + + assert_eq!( + codex_client_version_from_release_json(&release).as_deref(), + Some("0.147.0") + ); +} + +#[test] +fn chatgpt_codex_models_parse_visible_catalog_entries() { + let body = serde_json::json!({ + "models": [ + { + "slug": "gpt-5.6-sol", + "display_name": "GPT-5.6-Sol", + "visibility": "list", + "context_window": 272000, + "max_context_window": 272000, + "effective_context_window_percent": 95, + "input_modalities": ["text", "image"], + "supported_reasoning_levels": [ + {"effort": "low", "description": "Fast"}, + {"effort": "xhigh", "description": "Deep"}, + {"effort": "ultra", "description": "Delegated"} + ] + }, + { + "slug": "hidden", + "display_name": "Hidden", + "visibility": "hide", + "context_window": 128000, + "input_modalities": ["text"], + "supported_reasoning_levels": [] + }, + { + "slug": "gpt-text-only", + "display_name": "GPT Text Only", + "visibility": "list", + "max_context_window": 128000, + "effective_context_window_percent": 90, + "input_modalities": ["text"], + "supported_reasoning_levels": [{"effort": "medium"}] + } + ] + }); + + let models = chatgpt_models_from_codex_response(&body); + + assert_eq!(models.len(), 2); + assert_eq!(models[0].model_id, "gpt-5.6-sol"); + assert_eq!(models[0].display_name, "GPT-5.6-Sol"); + assert!(models[0].vision_supported); + assert_eq!(models[0].context_size, 272_000); + assert_eq!(models[0].max_input_tokens, Some(258_400)); + assert_eq!(models[0].reasoning_efforts, ["low", "xhigh", "ultra"]); + assert!(models[0].use_rig); + assert_eq!(models[0].provider.as_deref(), Some("openai")); + assert_eq!(models[0].supports_system_messages, Some(true)); + + assert_eq!(models[1].model_id, "gpt-text-only"); + assert!(!models[1].vision_supported); + assert_eq!(models[1].context_size, 128_000); + assert_eq!(models[1].max_input_tokens, Some(115_200)); +} + +#[test] +fn chatgpt_catalog_merge_drops_stale_models_but_preserves_overrides() { + let mut existing = openai_model("gpt-5.6-sol"); + existing.enabled = false; + existing.use_rig = false; + existing.capability_overrides.insert( + "vision".to_string(), + crate::settings::ModelCapabilityOverride::Unsupported, + ); + let stale = openai_model("stale-model"); + + let mut discovered = openai_model("gpt-5.6-sol"); + discovered.display_name = "GPT-5.6-Sol".to_string(); + discovered.vision_supported = true; + discovered.use_rig = true; + + let merged = merge_discovered_chatgpt_subscription_models(&[existing, stale], vec![discovered]); + + assert_eq!(merged.len(), 1); + assert_eq!(merged[0].model_id, "gpt-5.6-sol"); + assert!(!merged[0].enabled); + assert!(!merged[0].use_rig); + assert_eq!( + merged[0].capability_override("vision"), + crate::settings::ModelCapabilityOverride::Unsupported + ); +} diff --git a/app/src/ai/mod.rs b/app/src/ai/mod.rs index dba69676..c6bd6e20 100644 --- a/app/src/ai/mod.rs +++ b/app/src/ai/mod.rs @@ -55,6 +55,8 @@ pub mod provider; #[cfg(all(not(target_family = "wasm"), feature = "local_fs"))] pub(crate) mod remote_agent_context; pub(crate) mod remote_context_files; +#[cfg(not(target_family = "wasm"))] +pub(crate) mod remote_logging; pub mod request_usage_model; pub(crate) mod restored_conversations; pub(crate) mod runtime; diff --git a/app/src/ai/remote_logging.rs b/app/src/ai/remote_logging.rs new file mode 100644 index 00000000..4246a682 --- /dev/null +++ b/app/src/ai/remote_logging.rs @@ -0,0 +1,283 @@ +//! Opt-in remote AI diagnostics logger. +//! +//! This is intentionally separate from product telemetry. It is controlled +//! exclusively by local settings and should only receive operational metadata: +//! provider/model IDs, lifecycle states, counts, timings, and sanitized errors. + +use std::time::Duration; + +use chrono::Utc; +use galaxy_core::channel::ChannelState; +use galaxyui::{Entity, ModelContext, SingletonEntity}; +use serde::Serialize; +use serde_json::{json, Value}; +use settings::Setting; + +use crate::AISettings; + +const DEFAULT_LOGS_PATH: &str = "/api/logs"; +const REMOTE_LOG_SERVICE: &str = "galaxy-ai"; +const REMOTE_LOG_TIMEOUT: Duration = Duration::from_secs(5); +const MAX_ERROR_CHARS: usize = 500; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum RemoteLogLevel { + Info, + Warn, + Error, +} + +impl RemoteLogLevel { + fn as_str(self) -> &'static str { + match self { + Self::Info => "info", + Self::Warn => "warn", + Self::Error => "error", + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct RemoteLogRecord { + pub(crate) level: RemoteLogLevel, + pub(crate) message: String, + pub(crate) context: Value, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct RemoteLogConfig { + endpoint: String, + api_key: String, +} + +#[derive(Serialize)] +struct RemoteLogPayload { + level: &'static str, + message: String, + service: &'static str, + context: Value, +} + +impl RemoteLogConfig { + fn from_settings(settings: &AISettings) -> Option { + if !*settings.remote_logging_enabled.value() { + return None; + } + + let api_key = settings.remote_logging_api_key.value().trim(); + if api_key.is_empty() { + return None; + } + + let endpoint = normalize_endpoint_url(settings.remote_logging_endpoint.value())?; + Some(Self { + endpoint, + api_key: api_key.to_string(), + }) + } +} + +pub(crate) fn log_model_event(ctx: &mut ModelContext, record: RemoteLogRecord) +where + M: Entity, +{ + let Some(config) = RemoteLogConfig::from_settings(AISettings::as_ref(ctx)) else { + return; + }; + + let payload = RemoteLogPayload { + level: record.level.as_str(), + message: record.message, + service: REMOTE_LOG_SERVICE, + context: enrich_context(record.context), + }; + + let _ = ctx.spawn( + async move { send_remote_log(config, payload).await }, + |_, result, _| { + if let Err(error) = result { + log::warn!("[remote-logging] Failed to send remote AI log: {error}"); + } + }, + ); +} + +pub(crate) fn sanitize_error(error: impl std::fmt::Display) -> String { + let compact = error + .to_string() + .split_whitespace() + .map(redact_sensitive_token) + .collect::>() + .join(" "); + truncate_chars(&compact, MAX_ERROR_CHARS) +} + +pub(crate) fn raw_model_payload_context( + ctx: &ModelContext, + raw_payload: impl AsRef, +) -> Option +where + M: Entity, +{ + let settings = AISettings::as_ref(ctx); + if !*settings.remote_logging_enabled.value() + || !*settings.remote_logging_log_model_payloads.value() + || settings.remote_logging_api_key.value().trim().is_empty() + { + return None; + } + + let max_chars = (*settings.remote_logging_model_payload_max_chars.value()).max(1); + Some(tail_limited_payload_context( + raw_payload.as_ref(), + max_chars, + )) +} + +fn enrich_context(mut context: Value) -> Value { + let Value::Object(ref mut map) = context else { + return json!({ + "timestamp": Utc::now().to_rfc3339(), + "app": app_context(), + "details": context, + }); + }; + + map.insert("timestamp".to_string(), json!(Utc::now().to_rfc3339())); + map.insert("app".to_string(), app_context()); + context +} + +fn app_context() -> Value { + json!({ + "version": env!("CARGO_PKG_VERSION"), + "channel": ChannelState::channel().to_string(), + }) +} + +async fn send_remote_log(config: RemoteLogConfig, payload: RemoteLogPayload) -> Result<(), String> { + let client = reqwest::Client::builder() + .timeout(REMOTE_LOG_TIMEOUT) + .build() + .map_err(|error| format!("could not create HTTP client: {error}"))?; + + let response = client + .post(&config.endpoint) + .header("x-api-key", config.api_key) + .json(&payload) + .send() + .await + .map_err(|error| format!("request failed: {error}"))?; + + if !response.status().is_success() { + return Err(format!("server returned HTTP {}", response.status())); + } + + Ok(()) +} + +fn normalize_endpoint_url(endpoint: &str) -> Option { + let endpoint = endpoint.trim().trim_end_matches('/'); + if endpoint.is_empty() { + return None; + } + if endpoint.ends_with(DEFAULT_LOGS_PATH) { + Some(endpoint.to_string()) + } else { + Some(format!("{endpoint}{DEFAULT_LOGS_PATH}")) + } +} + +fn redact_sensitive_token(token: &str) -> &str { + let trimmed = token.trim_matches(|character: char| { + matches!(character, '"' | '\'' | ',' | ';' | ')' | ']' | '}') + }); + let lower = trimmed.to_ascii_lowercase(); + if trimmed.starts_with("sk-") + || trimmed.starts_with("log_sk_") + || lower.starts_with("bearer.") + || lower.starts_with("bearer:") + || lower == "bearer" + || lower == "authorization:" + || lower == "x-api-key:" + { + "[redacted]" + } else { + token + } +} + +fn truncate_chars(value: &str, max_chars: usize) -> String { + if value.chars().count() <= max_chars { + return value.to_string(); + } + let mut truncated = value.chars().take(max_chars).collect::(); + truncated.push('…'); + truncated +} + +fn tail_limited_payload_context(payload: &str, max_chars: usize) -> Value { + let total_chars = payload.chars().count(); + let truncated = total_chars > max_chars; + let payload = if truncated { + payload + .chars() + .skip(total_chars - max_chars) + .collect::() + } else { + payload.to_string() + }; + + json!({ + "payload": payload, + "payload_total_chars": total_chars, + "payload_included_chars": if truncated { max_chars } else { total_chars }, + "payload_max_chars": max_chars, + "truncated": truncated, + "truncation_strategy": if truncated { Some("tail") } else { None }, + }) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::{normalize_endpoint_url, sanitize_error, tail_limited_payload_context}; + + #[test] + fn endpoint_accepts_base_or_logs_path() { + assert_eq!( + normalize_endpoint_url("https://logging.ryserve.net").as_deref(), + Some("https://logging.ryserve.net/api/logs") + ); + assert_eq!( + normalize_endpoint_url("https://logging.ryserve.net/api/logs/").as_deref(), + Some("https://logging.ryserve.net/api/logs") + ); + } + + #[test] + fn errors_are_compacted_truncated_and_lightly_redacted() { + let error = format!("failed\nAuthorization: Bearer sk-test {}", "x".repeat(700)); + let sanitized = sanitize_error(error); + + assert!(!sanitized.contains("sk-test")); + assert!(!sanitized.contains('\n')); + assert!(sanitized.chars().count() <= 501); + } + + #[test] + fn raw_payload_cap_keeps_tail() { + assert_eq!( + tail_limited_payload_context("0123456789", 4), + json!({ + "payload": "6789", + "payload_total_chars": 10, + "payload_included_chars": 4, + "payload_max_chars": 4, + "truncated": true, + "truncation_strategy": "tail", + }) + ); + } +} diff --git a/app/src/appearance.rs b/app/src/appearance.rs index cf753475..63a14a9c 100644 --- a/app/src/appearance.rs +++ b/app/src/appearance.rs @@ -10,7 +10,7 @@ mod macos_app_icon { pub use objc2::rc::autoreleasepool; pub use objc2::{AnyThread, MainThreadMarker}; pub use objc2_app_kit::{NSApplication, NSImage, NSWorkspace, NSWorkspaceIconCreationOptions}; - pub use objc2_foundation::{ns_string, NSBundle, NSString}; + pub use objc2_foundation::{ns_string, NSBundle, NSData, NSString}; pub use crate::settings::app_icon::{AppIcon, AppIconSettings, AppIconSettingsChangedEvent}; } @@ -211,6 +211,7 @@ impl AppearanceManager { let ns_app = NSApplication::sharedApplication(mtm); let bundle = NSBundle::mainBundle(); let bundle_path = bundle.bundlePath(); + let is_bundled_app = bundle.bundleIdentifier().is_some(); let workspace = NSWorkspace::sharedWorkspace(); // If the user has selected the default icon, reset to the icon that is statically @@ -226,6 +227,7 @@ impl AppearanceManager { // override to display the default icon. This has the drawback of _not_ inheriting the // preferred icon style, but that icon style _will_ apply on next app restart. if icon == AppIcon::Galaxy + && is_bundled_app && ChannelState::channel() != Channel::Local && self.app_icon_at_startup == AppIcon::Galaxy { @@ -245,32 +247,20 @@ impl AppearanceManager { let icon_name = AppIconSettings::get_base_icon_file_name(icon); log::debug!("Setting app icon in memory to: {icon_name}"); - // Locate the plugin bundle. - let Some(plugins_path) = bundle.builtInPlugInsPath() else { - log::warn!("Failed to get dock tile plugin bundle"); - return; - }; - let plugin_name = ns_string!("WarpDockTilePlugin.docktileplugin"); - let plugin_path = plugins_path.stringByAppendingPathComponent(plugin_name); - let Some(plugin_bundle) = NSBundle::bundleWithPath(&plugin_path) else { - log::warn!("Failed to get dock tile plugin bundle"); - return; - }; - - // Read the images from the plugin bundle. - let image_name = NSString::from_str(icon_name); - let extension = ns_string!("png"); - let Some(image_path) = - plugin_bundle.pathForResource_ofType(Some(&image_name), Some(extension)) - else { - log::warn!("Failed to get image path for icon: {icon_name}"); - return; - }; - - // Create the image from the file. - let Some(image) = NSImage::initWithContentsOfFile(NSImage::alloc(), &image_path) else { - log::warn!("Failed to create image for icon: {icon_name}"); - return; + let image = if let Some(image) = load_app_icon_from_plugin_bundle(&bundle, icon_name) { + image + } else { + let asset_path = format!("bundled/png/{icon_name}.png"); + let Ok(image_bytes) = ASSETS.get(&asset_path) else { + log::warn!("Failed to get bundled app icon asset: {asset_path}"); + return; + }; + let data = NSData::with_bytes(image_bytes.as_ref()); + let Some(image) = NSImage::initWithData(NSImage::alloc(), &data) else { + log::warn!("Failed to create image from bundled app icon asset: {asset_path}"); + return; + }; + image }; // Override the bundled icon with this new image. @@ -291,6 +281,41 @@ impl AppearanceManager { } } +#[cfg(target_os = "macos")] +fn load_app_icon_from_plugin_bundle( + bundle: &NSBundle, + icon_name: &str, +) -> Option> { + let plugins_path = bundle.builtInPlugInsPath()?; + for plugin_bundle_name in [ + "GalaxyDockTilePlugin.docktileplugin", + "WarpDockTilePlugin.docktileplugin", + ] { + let plugin_name = NSString::from_str(plugin_bundle_name); + let plugin_path = plugins_path.stringByAppendingPathComponent(&plugin_name); + let Some(plugin_bundle) = NSBundle::bundleWithPath(&plugin_path) else { + continue; + }; + + let image_name = NSString::from_str(icon_name); + let extension = ns_string!("png"); + let Some(image_path) = + plugin_bundle.pathForResource_ofType(Some(&image_name), Some(extension)) + else { + log::warn!("Failed to get image path for icon {icon_name} from {plugin_bundle_name}"); + continue; + }; + + let Some(image) = NSImage::initWithContentsOfFile(NSImage::alloc(), &image_path) else { + log::warn!("Failed to create image for icon {icon_name} from {plugin_bundle_name}"); + continue; + }; + + return Some(image); + } + None +} + impl Entity for AppearanceManager { type Event = (); } diff --git a/app/src/settings/ai.rs b/app/src/settings/ai.rs index ea4c7b14..c972afa0 100644 --- a/app/src/settings/ai.rs +++ b/app/src/settings/ai.rs @@ -1032,10 +1032,94 @@ impl settings_value::SettingsValue for OpenAIProviderConfig {} const INITIAL_LITELLM_BASE_URL: &str = "https://ai.ryserve.net/v1"; const INITIAL_RIG_MODEL_ID: &str = "codex-gpt-5.6-sol-xhigh"; +fn default_acp_agent_id() -> String { + "codex".to_string() +} + +fn default_remote_logging_endpoint() -> String { + "https://logging.ryserve.net/api/logs".to_string() +} + +fn default_remote_logging_model_payload_max_chars() -> usize { + 100_000 +} + +fn acp_agent_display_name(agent_id: &str) -> String { + galaxy_acp::known_acp_agents() + .iter() + .find(|agent| agent.id.eq_ignore_ascii_case(agent_id.trim())) + .map(|agent| agent.name.to_string()) + .unwrap_or_else(|| agent_id.trim().to_string()) +} + +/// Configuration for a single Agent Client Protocol provider connection. +/// +/// ACP agents own their own model, login, session, and tool loop. Galaxy stores +/// enough connection metadata to launch the configured local agent and route +/// discovered model/mode entries back to the right connection. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, schemars::JsonSchema)] +#[schemars(description = "Configuration for an Agent Client Protocol provider connection.")] +pub struct AcpProviderConfig { + #[serde(default)] + #[schemars(description = "Stable local identifier for this ACP connection.")] + pub id: String, + #[serde(default = "default_enabled")] + #[schemars(description = "Whether this ACP connection is enabled for agent requests.")] + pub enabled: bool, + #[serde(default)] + #[schemars(description = "Display name for this ACP connection.")] + pub name: String, + #[serde(default = "default_acp_agent_id")] + #[schemars(description = "Identifier for the local Agent Client Protocol agent preset.")] + pub agent_id: String, + #[serde(default)] + #[schemars(description = "Executable used to launch this ACP agent.")] + pub command: String, + #[serde(default)] + #[schemars(description = "Arguments passed to this ACP agent executable.")] + pub args: Vec, + #[serde(default)] + #[schemars( + description = "Model, mode, and thought-level options discovered from this ACP agent." + )] + pub config_options: Vec, +} + +impl AcpProviderConfig { + pub(crate) fn new( + name: String, + agent_id: String, + command: String, + args: Vec, + config_options: Vec, + ) -> Self { + Self { + id: uuid::Uuid::new_v4().to_string(), + enabled: true, + name, + agent_id, + command, + args, + config_options, + } + } + + pub(crate) fn display_name(&self) -> String { + let name = self.name.trim(); + if name.is_empty() || name == "ACP agent runtime" { + acp_agent_display_name(self.agent_id.trim()) + } else { + name.to_string() + } + } +} + +impl settings_value::SettingsValue for AcpProviderConfig {} + fn default_chatgpt_models() -> Vec { - // The ChatGPT OAuth backend does not expose a model-listing capability through Rig, - // so keep this catalog small and explicit. Context limits come from Codex model - // metadata; models absent from that catalog retain the generic fallback. + // Fallback catalog used before the first successful Codex model discovery. + // Once discovery succeeds, the saved ChatGPT subscription catalog is treated + // as backend-owned so removed models do not get reintroduced on startup. [ ( "gpt-5.6-sol", @@ -1578,6 +1662,17 @@ define_settings_group!(AISettings, settings: [ description: "Arguments passed to the local Agent Client Protocol agent executable.", feature_flag: FeatureFlag::AgentClientProtocol, } + // Configured local ACP provider connections. + acp_providers: AcpProviders { + type: Vec, + default: Vec::new(), + supported_platforms: SupportedPlatforms::OR(SupportedPlatforms::MAC.into(), SupportedPlatforms::LINUX.into()), + sync_to_cloud: SyncToCloud::Never, + private: false, + toml_path: "ai.acp.providers", + description: "Configured Agent Client Protocol provider connections.", + feature_flag: FeatureFlag::AgentClientProtocol, + } // Cached ACP registry and runtime discovery data. Values are refreshed when the agent is queried. acp_agents: AcpAgents { type: Vec, @@ -1772,6 +1867,57 @@ define_settings_group!(AISettings, settings: [ toml_path: "ai.providers", description: "Multiple OpenAI-compatible provider endpoints (e.g. LiteLLM, Ollama, local models).", } + // Whether to send opt-in AI diagnostics to a remote logging endpoint. + remote_logging_enabled: RemoteLoggingEnabled { + type: bool, + default: false, + supported_platforms: SupportedPlatforms::DESKTOP, + sync_to_cloud: SyncToCloud::Never, + private: false, + toml_path: "ai.remote_logging.enabled", + description: "Whether to send opt-in AI diagnostics to the configured remote logger.", + } + // Endpoint for opt-in AI diagnostics. May be either the logger base URL or the full /api/logs URL. + remote_logging_endpoint: RemoteLoggingEndpoint { + type: String, + default: default_remote_logging_endpoint(), + supported_platforms: SupportedPlatforms::DESKTOP, + sync_to_cloud: SyncToCloud::Never, + private: false, + toml_path: "ai.remote_logging.endpoint", + description: "Remote logging endpoint for opt-in AI diagnostics.", + } + // API key used to write opt-in AI diagnostics to the remote logger. Kept local only. + remote_logging_api_key: RemoteLoggingApiKey { + type: String, + default: String::new(), + supported_platforms: SupportedPlatforms::DESKTOP, + sync_to_cloud: SyncToCloud::Never, + private: false, + toml_path: "ai.remote_logging.api_key", + description: "API key used to write opt-in AI diagnostics to the remote logger.", + } + // Whether to include raw model request and response payloads in opt-in AI diagnostics. + // This can include prompts, model output, tool arguments, and file contents. + remote_logging_log_model_payloads: RemoteLoggingLogModelPayloads { + type: bool, + default: false, + supported_platforms: SupportedPlatforms::DESKTOP, + sync_to_cloud: SyncToCloud::Never, + private: false, + toml_path: "ai.remote_logging.log_model_payloads", + description: "Whether opt-in AI diagnostics include raw model request and response payloads.", + } + // Maximum number of trailing characters kept for each raw model payload log. + remote_logging_model_payload_max_chars: RemoteLoggingModelPayloadMaxChars { + type: usize, + default: default_remote_logging_model_payload_max_chars(), + supported_platforms: SupportedPlatforms::DESKTOP, + sync_to_cloud: SyncToCloud::Never, + private: false, + toml_path: "ai.remote_logging.model_payload_max_chars", + description: "Maximum number of trailing characters to send for each raw model payload diagnostic event.", + } // Whether or not the user wants agent mode requests to use their saved rules. memory_enabled: MemoryEnabled { type: bool, @@ -2315,6 +2461,68 @@ impl AISettings { && !self.is_ai_disabled_due_to_remote_session_org_policy(app) } + pub(crate) fn configured_acp_providers(&self) -> Vec { + let providers = self + .acp_providers + .value() + .iter() + .filter(|provider| !provider.agent_id.trim().is_empty()) + .cloned() + .collect::>(); + if !providers.is_empty() { + return providers; + } + + self.legacy_acp_provider().into_iter().collect() + } + + pub(crate) fn enabled_acp_providers(&self) -> Vec { + if !*self.acp_enabled.value() { + return Vec::new(); + } + self.configured_acp_providers() + .into_iter() + .filter(|provider| provider.enabled) + .collect() + } + + pub(crate) fn enabled_acp_provider_by_id( + &self, + provider_id: &str, + ) -> Option { + self.enabled_acp_providers() + .into_iter() + .find(|provider| provider.id == provider_id) + } + + pub(crate) fn legacy_acp_provider(&self) -> Option { + if !*self.acp_enabled.value() { + return None; + } + let agent_id = self.acp_agent_id.value().trim(); + let agent_id = if agent_id.is_empty() { + "codex" + } else { + agent_id + }; + let config_options = self + .acp_agents + .value() + .iter() + .find(|agent| agent.id.eq_ignore_ascii_case(agent_id)) + .map(|agent| agent.config_options.clone()) + .unwrap_or_default(); + Some(AcpProviderConfig { + id: "legacy".to_string(), + enabled: true, + name: self.acp_connection_name.value().clone(), + agent_id: agent_id.to_string(), + command: self.acp_agent_command.value().clone(), + args: self.acp_agent_args.value().clone(), + config_options, + }) + } + /// Returns whether Galaxy has a local model provider or agent runtime enabled. pub fn has_enabled_ai_runtime(&self) -> bool { *self.bedrock_enabled.value() diff --git a/app/src/settings_view/ai_page.rs b/app/src/settings_view/ai_page.rs index b32d4fa4..7c5a2978 100644 --- a/app/src/settings_view/ai_page.rs +++ b/app/src/settings_view/ai_page.rs @@ -96,7 +96,7 @@ use crate::server::telemetry::{ use crate::settings::ai::OpenAIProviderKind; use crate::settings::{ AIAutoDetectionEnabled, AICommandDenylist, AISettings, AISettingsChangedEvent, AcpEnabled, - AgentModeCodingPermissionsType, AgentModeCommandExecutionDenylist, + AcpProviderConfig, AgentModeCodingPermissionsType, AgentModeCommandExecutionDenylist, AgentModeCommandExecutionPredicate, AgentModeQuerySuggestionsEnabled, BedrockAutoLogin, BedrockEnabled, BedrockModelConfig, CodeSettings, CodebaseContextEnabled, CrosscheckEnabled, FileBasedMcpEnabled, GitOperationsAutogenEnabled, IncludeAgentCommandsInHistory, InputSettings, @@ -2054,7 +2054,24 @@ impl AISettingsPageView { fn save_acp_provider(&mut self, draft: AcpProviderDraft, ctx: &mut ViewContext) { AISettings::handle(ctx).update(ctx, |settings, ctx| { + let legacy_provider = if settings.acp_providers.value().is_empty() { + settings.legacy_acp_provider() + } else { + None + }; + let mut providers = settings.acp_providers.value().clone(); + if let Some(legacy_provider) = legacy_provider { + providers.push(legacy_provider); + } + providers.push(AcpProviderConfig::new( + draft.name.clone(), + draft.agent_id.clone(), + draft.command.clone(), + draft.args.clone(), + draft.config_options.clone(), + )); report_if_error!(settings.acp_enabled.set_value(true, ctx)); + report_if_error!(settings.acp_providers.set_value(providers, ctx)); report_if_error!(settings.acp_agent_id.set_value(draft.agent_id, ctx)); report_if_error!(settings.acp_agent_command.set_value(draft.command, ctx)); report_if_error!(settings.acp_agent_args.set_value(draft.args, ctx)); @@ -2989,6 +3006,7 @@ pub enum AISettingsPageAction { RemoveOpenAIProvider(usize), EditBedrockProvider, RemoveBedrockProvider, + RemoveAcpProvider(String), ToggleFileBasedMcp, ToggleIncludeAgentCommandsInHistory, ToggleAgentAttribution, @@ -3774,6 +3792,51 @@ impl TypedActionView for AISettingsPageView { }); self.rebuild_active_subpage(ctx); } + AISettingsPageAction::RemoveAcpProvider(provider_id) => { + AISettings::handle(ctx).update(ctx, |settings, ctx| { + let mut providers = settings.acp_providers.value().clone(); + if providers.is_empty() && provider_id == "legacy" { + report_if_error!(settings.acp_enabled.set_value(false, ctx)); + report_if_error!(settings.acp_agent_id.set_value("codex".to_string(), ctx)); + report_if_error!(settings.acp_agent_command.set_value(String::new(), ctx)); + report_if_error!(settings.acp_agent_args.set_value(Vec::new(), ctx)); + report_if_error!(settings + .acp_connection_name + .set_value("ACP agent runtime".to_string(), ctx,)); + report_if_error!(settings.acp_agents.set_value(Vec::new(), ctx)); + return; + } + + providers.retain(|provider| provider.id != provider_id.as_str()); + report_if_error!(settings.acp_providers.set_value(providers.clone(), ctx)); + + if let Some(first_provider) = providers.first() { + report_if_error!(settings.acp_enabled.set_value(true, ctx)); + report_if_error!(settings + .acp_agent_id + .set_value(first_provider.agent_id.clone(), ctx)); + report_if_error!(settings + .acp_agent_command + .set_value(first_provider.command.clone(), ctx)); + report_if_error!(settings + .acp_agent_args + .set_value(first_provider.args.clone(), ctx)); + report_if_error!(settings + .acp_connection_name + .set_value(first_provider.name.clone(), ctx)); + } else { + report_if_error!(settings.acp_enabled.set_value(false, ctx)); + report_if_error!(settings.acp_agent_id.set_value("codex".to_string(), ctx)); + report_if_error!(settings.acp_agent_command.set_value(String::new(), ctx)); + report_if_error!(settings.acp_agent_args.set_value(Vec::new(), ctx)); + report_if_error!(settings + .acp_connection_name + .set_value("ACP agent runtime".to_string(), ctx,)); + report_if_error!(settings.acp_agents.set_value(Vec::new(), ctx)); + } + }); + self.clear_inline_provider_setup(ctx); + } AISettingsPageAction::ToggleFileBasedMcp => { AISettings::handle(ctx).update(ctx, |settings, ctx| { report_if_error!(settings.file_based_mcp_enabled.toggle_and_save_value(ctx)); @@ -7114,7 +7177,7 @@ impl SettingsWidget for ModelsOverviewWidget { .map(|provider| provider.models.len()) .sum::(); let bedrock_model_count = settings.bedrock_models.value().len(); - let agent_runtime_count = usize::from(*settings.acp_enabled.value()); + let agent_runtime_count = settings.enabled_acp_providers().len(); Flex::column() .with_spacing(8.) @@ -7161,6 +7224,10 @@ struct OpenAIProviderCardState { remove_button: ViewHandle, } +struct AcpProviderCardState { + remove_button: ViewHandle, +} + struct ProviderSettingsWidget { provider_type: ProviderSetupProviderType, enabled_toggle: SwitchStateHandle, @@ -7173,6 +7240,7 @@ struct ProviderSettingsWidget { bedrock_edit_button: ViewHandle, bedrock_remove_button: ViewHandle, provider_cards: Vec, + acp_provider_cards: Vec, } impl ProviderSettingsWidget { @@ -7200,6 +7268,25 @@ impl ProviderSettingsWidget { }), }) .collect(); + let acp_providers = AISettings::as_ref(ctx).configured_acp_providers(); + let acp_provider_cards = acp_providers + .iter() + .map(|provider| { + let provider_id = provider.id.clone(); + AcpProviderCardState { + remove_button: ctx.add_typed_action_view(move |_| { + ActionButton::new("Delete", DangerSecondaryTheme).on_click({ + let provider_id = provider_id.clone(); + move |ctx| { + ctx.dispatch_typed_action(AISettingsPageAction::RemoveAcpProvider( + provider_id.clone(), + )); + } + }) + }), + } + }) + .collect(); let add_openai_provider_button = ctx.add_typed_action_view(|_| { ActionButton::new("Add provider", SecondaryTheme) .with_icon(Icon::Plus) @@ -7253,7 +7340,6 @@ impl ProviderSettingsWidget { ctx.dispatch_typed_action(AISettingsPageAction::RemoveBedrockProvider); }) }); - Self { provider_type, enabled_toggle: SwitchStateHandle::default(), @@ -7266,6 +7352,7 @@ impl ProviderSettingsWidget { bedrock_edit_button, bedrock_remove_button, provider_cards, + acp_provider_cards, } } @@ -7439,15 +7526,11 @@ impl ProviderSettingsWidget { Self::render_model_catalog(rows, "No Bedrock models discovered yet.", appearance) } - fn render_acp_model_catalog(appearance: &Appearance, app: &AppContext) -> Box { - let settings = AISettings::as_ref(app); - let selected_agent_id = settings.acp_agent_id.value(); - let Some(agent) = settings - .acp_agents - .value() - .iter() - .find(|agent| agent.id.eq_ignore_ascii_case(selected_agent_id)) - else { + fn render_acp_model_catalog( + provider: &AcpProviderConfig, + appearance: &Appearance, + ) -> Box { + if provider.config_options.is_empty() { return Text::new( "No ACP model or mode catalog has been discovered yet.", appearance.ui_font_family(), @@ -7456,9 +7539,9 @@ impl ProviderSettingsWidget { .with_color(appearance.theme().nonactive_ui_text_color().into()) .soft_wrap(true) .finish(); - }; + } - let rows = agent + let rows = provider .config_options .iter() .filter(|option| { @@ -7672,13 +7755,15 @@ impl ProviderSettingsWidget { fn render_acp_provider_card( &self, - title: &str, + provider_index: usize, + provider: &AcpProviderConfig, description: &'static str, appearance: &Appearance, - app: &AppContext, ) -> Box { - let settings = AISettings::as_ref(app); - let status = format!("Read-only · Agent: {}", settings.acp_agent_id.value()); + let Some(card_state) = self.acp_provider_cards.get(provider_index) else { + return Empty::new().finish(); + }; + let status = format!("Read-only · Agent: {}", provider.agent_id); let header = Flex::row() .with_main_axis_size(MainAxisSize::Max) .with_main_axis_alignment(MainAxisAlignment::SpaceBetween) @@ -7688,7 +7773,7 @@ impl ProviderSettingsWidget { .with_spacing(4.) .with_child( Text::new( - title.to_string(), + provider.display_name(), appearance.ui_font_family(), appearance.header_font_size(), ) @@ -7705,8 +7790,15 @@ impl ProviderSettingsWidget { .finish(), ) .with_child( - Text::new(status, appearance.ui_font_family(), CONTENT_FONT_SIZE) - .with_color(appearance.theme().nonactive_ui_text_color().into()) + Flex::row() + .with_spacing(8.) + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_child( + Text::new(status, appearance.ui_font_family(), CONTENT_FONT_SIZE) + .with_color(appearance.theme().nonactive_ui_text_color().into()) + .finish(), + ) + .with_child(ChildView::new(&card_state.remove_button).finish()) .finish(), ) .finish(); @@ -7715,7 +7807,7 @@ impl ProviderSettingsWidget { Flex::column() .with_spacing(12.) .with_child(header) - .with_child(Self::render_acp_model_catalog(appearance, app)) + .with_child(Self::render_acp_model_catalog(provider, appearance)) .finish(), ) .with_padding(Padding::uniform(16.)) @@ -7977,22 +8069,28 @@ impl SettingsWidget for ProviderSettingsWidget { } let acp_supported = cfg!(unix) && FeatureFlag::AgentClientProtocol.is_enabled(); - let acp_cards = if acp_supported && *settings.acp_enabled.value() { - vec![self.render_acp_provider_card( - settings.acp_connection_name.value().as_str(), + let acp_cards = if acp_supported { + settings + .configured_acp_providers() + .iter() + .enumerate() + .map(|(index, provider)| { + self.render_acp_provider_card( + index, + provider, "Use a local session-oriented agent that owns its model, login, session, and tool loop.", appearance, - app, - )] + ) + }) + .collect::>() } else { Vec::new() }; - let acp_add_button = - if acp_supported && !is_setup_visible && !*settings.acp_enabled.value() { - Some(&self.acp_add_button) - } else { - None - }; + let acp_add_button = if acp_supported && !is_setup_visible { + Some(&self.acp_add_button) + } else { + None + }; let empty_message = if acp_supported { "No ACP agent connection configured." } else { diff --git a/app/src/settings_view/provider_setup_view.rs b/app/src/settings_view/provider_setup_view.rs index 44a30bad..8cfa7e1c 100644 --- a/app/src/settings_view/provider_setup_view.rs +++ b/app/src/settings_view/provider_setup_view.rs @@ -996,6 +996,10 @@ impl ProviderSetupView { | ProviderSetupProviderType::VertexAI => {} } + self.discovery_state = DiscoveryState::Loading; + self.update_next_button(ctx); + ctx.notify(); + let provider = self.draft_provider(); let existing_models = self.draft_models.clone(); ctx.spawn( @@ -1033,15 +1037,34 @@ impl ProviderSetupView { return; } - if self.draft_models.is_empty() { - self.draft_models = crate::settings::ai::default_chatgpt_provider().models; - } - self.discovery_state = DiscoveryState::Idle; - self.step = ProviderSetupStep::Models; - self.sync_model_switches(ctx); + self.discovery_state = DiscoveryState::Loading; self.update_next_button(ctx); - ctx.focus(&self.name_editor); ctx.notify(); + + let provider = self.draft_provider(); + let existing_models = self.draft_models.clone(); + ctx.spawn( + async move { LLMPreferences::discover_openai_provider_models(provider).await }, + move |me, result, ctx| match result { + Ok(models) => { + me.draft_models = crate::ai::llms::merge_discovered_chatgpt_subscription_models( + &existing_models, + models, + ); + me.discovery_state = DiscoveryState::Idle; + me.step = ProviderSetupStep::Models; + me.sync_model_switches(ctx); + me.update_next_button(ctx); + ctx.focus(&me.name_editor); + ctx.notify(); + } + Err(error) => { + me.discovery_state = DiscoveryState::Failed(error); + me.update_next_button(ctx); + ctx.notify(); + } + }, + ); } #[cfg(target_family = "wasm")] diff --git a/crates/persistence/src/model.rs b/crates/persistence/src/model.rs index 70c797fd..03a82ea1 100644 --- a/crates/persistence/src/model.rs +++ b/crates/persistence/src/model.rs @@ -1043,6 +1043,7 @@ impl AgentBackend { match self { Self::Provider => Self::Provider, Self::Acp(acp) => Self::Acp(AcpConversationData { + provider_id: acp.provider_id.clone(), agent_id: acp.agent_id.clone(), launch_fingerprint: acp.launch_fingerprint.clone(), session_id: None, @@ -1059,6 +1060,8 @@ impl AgentBackend { /// different executable after those settings change. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] pub struct AcpConversationData { + #[serde(default, skip_serializing_if = "String::is_empty")] + pub provider_id: String, #[serde(default)] pub agent_id: String, #[serde(default, skip_serializing_if = "String::is_empty")] diff --git a/crates/persistence/src/model_tests.rs b/crates/persistence/src/model_tests.rs index 62da6edf..8b31b4c7 100644 --- a/crates/persistence/src/model_tests.rs +++ b/crates/persistence/src/model_tests.rs @@ -9,6 +9,7 @@ use super::{ #[test] fn acp_backend_fork_keeps_agent_identity_but_clears_session() { let source = AgentBackend::Acp(AcpConversationData { + provider_id: "provider-1".to_owned(), agent_id: "codex".to_owned(), launch_fingerprint: "launch-123".to_owned(), session_id: Some("shared-session".to_owned()), @@ -21,6 +22,7 @@ fn acp_backend_fork_keeps_agent_identity_but_clears_session() { assert_eq!( source.for_fork(), AgentBackend::Acp(AcpConversationData { + provider_id: "provider-1".to_owned(), agent_id: "codex".to_owned(), launch_fingerprint: "launch-123".to_owned(), session_id: None, @@ -183,6 +185,7 @@ fn agent_conversation_data_defaults_legacy_rows_to_provider_backend() { fn agent_conversation_data_roundtrips_acp_backend() { let data = AgentConversationData { agent_backend: AgentBackend::Acp(AcpConversationData { + provider_id: "provider-1".to_string(), agent_id: "codex-acp".to_string(), launch_fingerprint: "launch-123".to_string(), session_id: Some("session-123".to_string()), diff --git a/script/macos/bundle b/script/macos/bundle index 3c2c4234..6325fca9 100755 --- a/script/macos/bundle +++ b/script/macos/bundle @@ -317,7 +317,8 @@ elif [[ $RELEASE_CHANNEL = "oss" ]]; then fi OUT_DIR="target/$TARGET_PROFILE_DIR/bundle/osx" -DOCK_TILE_PLUGIN_DIR="target/$TARGET_PROFILE_DIR/WarpDockTilePlugin.docktileplugin" +DOCK_TILE_PLUGIN_NAME="GalaxyDockTilePlugin.docktileplugin" +DOCK_TILE_PLUGIN_DIR="target/$TARGET_PROFILE_DIR/$DOCK_TILE_PLUGIN_NAME" # Handle specific architecture targeting if [[ -n "$TARGET_ARCH" ]]; then @@ -515,11 +516,16 @@ if [[ "$ARTIFACT" == "app" ]]; then # Note that the dock tile plugin is pre-built for both arm64 and x86_64 so we don't need to run lipo on it. echo "Creating PlugIns directory and copying pre-built DockTilePlugin..." mkdir -p "$BUNDLE_DIR/$WARP_APP_NAME.app/Contents/PlugIns" + rm -rf "$BUNDLE_DIR/$WARP_APP_NAME.app/Contents/PlugIns/$DOCK_TILE_PLUGIN_NAME" cp -R "$DOCK_TILE_PLUGIN_DIR" "$BUNDLE_DIR/$WARP_APP_NAME.app/Contents/PlugIns/" echo "Updating plist with dock tile plugin entries" - plutil -insert NSDockTilePlugIn -string "WarpDockTilePlugin.docktileplugin" "$BUNDLE_DIR"/$WARP_APP_NAME.app/Contents/Info.plist - plutil -insert MainAppBundleIdentifier -string "$BUNDLE_ID" "$BUNDLE_DIR"/$WARP_APP_NAME.app/Contents/PlugIns/WarpDockTilePlugin.docktileplugin/Contents/Info.plist + APP_BUNDLE_PLIST="$BUNDLE_DIR/$WARP_APP_NAME.app/Contents/Info.plist" + APP_BUNDLE_ID=$(/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "$APP_BUNDLE_PLIST" 2>/dev/null || echo "$BUNDLE_ID") + plutil -insert NSDockTilePlugIn -string "$DOCK_TILE_PLUGIN_NAME" "$APP_BUNDLE_PLIST" 2>/dev/null || \ + plutil -replace NSDockTilePlugIn -string "$DOCK_TILE_PLUGIN_NAME" "$APP_BUNDLE_PLIST" + plutil -insert MainAppBundleIdentifier -string "$APP_BUNDLE_ID" "$BUNDLE_DIR/$WARP_APP_NAME.app/Contents/PlugIns/$DOCK_TILE_PLUGIN_NAME/Contents/Info.plist" 2>/dev/null || \ + plutil -replace MainAppBundleIdentifier -string "$APP_BUNDLE_ID" "$BUNDLE_DIR/$WARP_APP_NAME.app/Contents/PlugIns/$DOCK_TILE_PLUGIN_NAME/Contents/Info.plist" BUNDLED_RESOURCES_DIR="$BUNDLE_DIR/$WARP_APP_NAME.app/Contents/Resources" echo "Preparing bundled resources..." diff --git a/script/macos/run b/script/macos/run index dac894d9..3d923c8e 100755 --- a/script/macos/run +++ b/script/macos/run @@ -36,6 +36,7 @@ else WARP_APP_PATH="${TARGET_DIR}/debug/bundle/osx/Galaxy.app" WARP_SCHEME_NAME="galaxyoss" fi +TARGET_PROFILE_DIR="debug" DONT_OPEN=false # Launches the binary with "open", meaning the Galaxy process is # launched by the MacOS application launcher instead of a shell session. @@ -67,6 +68,7 @@ while (( "$#" )); do ;; --release) echo "Detected release build, pointing at release bundle under ${TARGET_DIR}/release/bundle" + TARGET_PROFILE_DIR="release" if [ "$WARP_CHANNEL" = "local" ]; then WARP_APP_PATH="${TARGET_DIR}/release/bundle/osx/Galaxy Local.app" else @@ -77,6 +79,7 @@ while (( "$#" )); do ;; --profile) PROFILE="$2" + TARGET_PROFILE_DIR="$PROFILE" shift 2 if [ "$WARP_CHANNEL" = "local" ]; then WARP_APP_PATH="${TARGET_DIR}/${PROFILE}/bundle/osx/Galaxy Local.app" @@ -127,6 +130,23 @@ if [ "${GENERATE_SCHEMA:-false}" != "true" ]; then fi NO_LICENSES=1 "${REPO_ROOT}/script/prepare_bundled_resources" "$WARP_APP_PATH/Contents/Resources" "$WARP_CHANNEL" +DOCK_TILE_PLUGIN_NAME="GalaxyDockTilePlugin.docktileplugin" +DOCK_TILE_PLUGIN_DIR="${TARGET_DIR}/${TARGET_PROFILE_DIR}/${DOCK_TILE_PLUGIN_NAME}" +if [ -d "$DOCK_TILE_PLUGIN_DIR" ]; then + echo "Copying DockTilePlugin into app bundle..." + mkdir -p "$WARP_APP_PATH/Contents/PlugIns" + rm -rf "$WARP_APP_PATH/Contents/PlugIns/$DOCK_TILE_PLUGIN_NAME" + cp -R "$DOCK_TILE_PLUGIN_DIR" "$WARP_APP_PATH/Contents/PlugIns/" + + APP_BUNDLE_ID=$(/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "$WARP_APP_PATH/Contents/Info.plist") + plutil -insert NSDockTilePlugIn -string "$DOCK_TILE_PLUGIN_NAME" "$WARP_APP_PATH/Contents/Info.plist" 2>/dev/null || \ + plutil -replace NSDockTilePlugIn -string "$DOCK_TILE_PLUGIN_NAME" "$WARP_APP_PATH/Contents/Info.plist" + plutil -insert MainAppBundleIdentifier -string "$APP_BUNDLE_ID" "$WARP_APP_PATH/Contents/PlugIns/$DOCK_TILE_PLUGIN_NAME/Contents/Info.plist" 2>/dev/null || \ + plutil -replace MainAppBundleIdentifier -string "$APP_BUNDLE_ID" "$WARP_APP_PATH/Contents/PlugIns/$DOCK_TILE_PLUGIN_NAME/Contents/Info.plist" +else + echo "Warning: DockTilePlugin not found at $DOCK_TILE_PLUGIN_DIR; app icon changes will use the runtime fallback only." >&2 +fi + "${REPO_ROOT}/script/compile_icon" "$WARP_CHANNEL" "$WARP_APP_PATH" if [[ ",$FEATURES," =~ ",heap_usage_tracking," ]]; then