adding logging, cleaning up configs

This commit is contained in:
2026-08-12 06:38:02 -05:00
parent 84945cd9be
commit 1ad2ab4010
24 changed files with 2504 additions and 208 deletions
+23
View File
@@ -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<String, serde_json::Value>,
@@ -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, serde_json::Value>,
) -> 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<String, serde_json::Value>,
+2
View File
@@ -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()),
+3 -2
View File
@@ -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};
+19 -3
View File
@@ -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()),
+3
View File
@@ -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()),
+400
View File
@@ -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<String> {
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<String> {
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<BlocklistAIActionModel>,
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<BlocklistAIActionExecutor>,
@@ -927,6 +1206,21 @@ impl BlocklistAIActionModel {
ctx: &mut ModelContext<Self>,
) {
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::<Vec<_>>(),
}),
);
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 {
+36 -5
View File
@@ -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<LLMId> {
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,
@@ -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<String, serde_json::Value> {
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<Self>,
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<Self>,
) {
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<Self>,
) {
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<Self>,
) {
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<str>,
ctx: &mut ModelContext<Self>,
) {
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,
&params,
&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,
&params,
&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<Self>) {
#[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(&params.message_history)
.unwrap_or_else(|_| serde_json::json!(format!("{:#?}", params.message_history))),
"progressive_summary": &params.progressive_summary,
"tool_result_archive": serde_json::to_value(&params.tool_result_archive)
.unwrap_or_else(|_| serde_json::json!(format!("{:#?}", params.tool_result_archive))),
"global_rules": &params.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<response_event::stream_finished::Reason>,
) -> &'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::<Vec<_>>(),
})
}
#[cfg(not(target_family = "wasm"))]
fn is_interactive_remote_command(command: &str) -> bool {
is_potential_remote_ssh_command(command)
+14
View File
@@ -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]
+13 -17
View File
@@ -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,
),
})
}
@@ -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,
+57 -2
View File
@@ -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<String>,
}
/// 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<ChatGPTAuthCredentials, String> {
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<ChatGPTAuthCredentials, String> {
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<std::path::PathBuf> {
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<String>,
}
#[derive(serde::Serialize)]
#[derive(serde::Deserialize, serde::Serialize)]
struct AuthRecord {
access_token: Option<String>,
refresh_token: Option<String>,
+435 -79
View File
@@ -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<OpenAIModelConfig>,
#[cfg(not(target_family = "wasm"))]
chatgpt_subscription_models_refresh_in_flight: bool,
#[cfg(not(target_family = "wasm"))]
acp_selections: HashMap<LLMId, AcpModelSelection>,
}
#[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<String, serde_json::Value>,
}
@@ -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<String, serde_json::Value>,
@@ -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<Vec<OpenAIModelConfig>, 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<Self>) {
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<Self>) {
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<Vec<OpenAIModelConfig>, 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<RigModelInfo>) -> Vec<OpenAIModelConfig> {
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<OpenAIModelConfig>,
) -> Vec<OpenAIModelConfig> {
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<u32> {
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<String> {
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<String> {
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<String, String> {
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<Vec<OpenAIModelConfig>, 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::<String>()
));
}
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<OpenAIModelConfig> {
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::<Vec<_>>()
})
.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).
+186 -12
View File
@@ -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<_>>(),
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::<Vec<_>>();
assert_eq!(models.len(), 2);
assert_eq!(
models
.iter()
.map(|model| model.display_name.as_str())
.collect::<HashSet<_>>(),
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<_>>(),
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
);
}
+2
View File
@@ -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;
+283
View File
@@ -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<Self> {
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<M>(ctx: &mut ModelContext<M>, 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::<Vec<_>>()
.join(" ");
truncate_chars(&compact, MAX_ERROR_CHARS)
}
pub(crate) fn raw_model_payload_context<M>(
ctx: &ModelContext<M>,
raw_payload: impl AsRef<str>,
) -> Option<Value>
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<String> {
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::<String>();
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::<String>()
} 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",
})
);
}
}
+52 -27
View File
@@ -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<objc2::rc::Retained<NSImage>> {
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 = ();
}
+211 -3
View File
@@ -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<String>,
#[serde(default)]
#[schemars(
description = "Model, mode, and thought-level options discovered from this ACP agent."
)]
pub config_options: Vec<AcpConfigOptionSettings>,
}
impl AcpProviderConfig {
pub(crate) fn new(
name: String,
agent_id: String,
command: String,
args: Vec<String>,
config_options: Vec<AcpConfigOptionSettings>,
) -> 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<OpenAIModelConfig> {
// 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<AcpProviderConfig>,
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<AcpAgentSettings>,
@@ -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<AcpProviderConfig> {
let providers = self
.acp_providers
.value()
.iter()
.filter(|provider| !provider.agent_id.trim().is_empty())
.cloned()
.collect::<Vec<_>>();
if !providers.is_empty() {
return providers;
}
self.legacy_acp_provider().into_iter().collect()
}
pub(crate) fn enabled_acp_providers(&self) -> Vec<AcpProviderConfig> {
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<AcpProviderConfig> {
self.enabled_acp_providers()
.into_iter()
.find(|provider| provider.id == provider_id)
}
pub(crate) fn legacy_acp_provider(&self) -> Option<AcpProviderConfig> {
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()
+131 -33
View File
@@ -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<Self>) {
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::<usize>();
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<ActionButton>,
}
struct AcpProviderCardState {
remove_button: ViewHandle<ActionButton>,
}
struct ProviderSettingsWidget {
provider_type: ProviderSetupProviderType,
enabled_toggle: SwitchStateHandle,
@@ -7173,6 +7240,7 @@ struct ProviderSettingsWidget {
bedrock_edit_button: ViewHandle<ActionButton>,
bedrock_remove_button: ViewHandle<ActionButton>,
provider_cards: Vec<OpenAIProviderCardState>,
acp_provider_cards: Vec<AcpProviderCardState>,
}
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<dyn Element> {
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<dyn Element> {
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<dyn Element> {
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::<Vec<_>>()
} 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 {
+30 -7
View File
@@ -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")]
+3
View File
@@ -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")]
+3
View File
@@ -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()),
+9 -3
View File
@@ -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..."
+20
View File
@@ -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