adding logging, cleaning up configs
This commit is contained in:
@@ -10,6 +10,14 @@ pub(crate) fn acp_model_id(agent_id: &str) -> String {
|
|||||||
format!("acp:{}", agent_id.trim().to_ascii_lowercase())
|
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(
|
pub(crate) fn acp_selection_model_id(
|
||||||
agent_id: &str,
|
agent_id: &str,
|
||||||
values: &std::collections::BTreeMap<String, serde_json::Value>,
|
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(
|
pub(crate) fn acp_selection_identity(
|
||||||
agent_id: &str,
|
agent_id: &str,
|
||||||
values: &std::collections::BTreeMap<String, serde_json::Value>,
|
values: &std::collections::BTreeMap<String, serde_json::Value>,
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ fn persisted_sessions_require_the_same_launch_identity() {
|
|||||||
let args = vec!["serve".to_owned()];
|
let args = vec!["serve".to_owned()];
|
||||||
let launch = resolve_acp_launch("custom", command, &args).unwrap();
|
let launch = resolve_acp_launch("custom", command, &args).unwrap();
|
||||||
let backend = AcpConversationData {
|
let backend = AcpConversationData {
|
||||||
|
provider_id: String::new(),
|
||||||
agent_id: "custom".to_owned(),
|
agent_id: "custom".to_owned(),
|
||||||
launch_fingerprint: acp_launch_fingerprint("custom", command, &args),
|
launch_fingerprint: acp_launch_fingerprint("custom", command, &args),
|
||||||
session_id: Some("session-123".to_owned()),
|
session_id: Some("session-123".to_owned()),
|
||||||
@@ -139,6 +140,7 @@ fn persisted_sessions_require_the_same_launch_identity() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn legacy_acp_sessions_fail_closed_without_a_launch_fingerprint() {
|
fn legacy_acp_sessions_fail_closed_without_a_launch_fingerprint() {
|
||||||
let backend = AcpConversationData {
|
let backend = AcpConversationData {
|
||||||
|
provider_id: String::new(),
|
||||||
agent_id: "codex".to_owned(),
|
agent_id: "codex".to_owned(),
|
||||||
launch_fingerprint: String::new(),
|
launch_fingerprint: String::new(),
|
||||||
session_id: Some("legacy-session".to_owned()),
|
session_id: Some("legacy-session".to_owned()),
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ mod runtime_model;
|
|||||||
mod transport;
|
mod transport;
|
||||||
|
|
||||||
pub(crate) use launch::{
|
pub(crate) use launch::{
|
||||||
acp_launch_fingerprint, acp_model_id, acp_selection_identity, acp_selection_model_id,
|
acp_launch_fingerprint, acp_model_id, acp_provider_selection_identity, acp_selection_identity,
|
||||||
resolve_acp_launch, validate_acp_dispatch, validate_acp_launch_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 permissions::resolve_acp_permissions;
|
||||||
pub(crate) use runtime_model::{AcpDiscoveryState, AcpRuntimeModel};
|
pub(crate) use runtime_model::{AcpDiscoveryState, AcpRuntimeModel};
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ use galaxy_agent_core::{
|
|||||||
TurnRequest,
|
TurnRequest,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::launch::acp_selection_identity;
|
use super::launch::{acp_provider_selection_identity, acp_selection_identity};
|
||||||
use super::prompt::{prompt_content, GalaxyTerminalTools};
|
use super::prompt::{prompt_content, GalaxyTerminalTools};
|
||||||
use crate::ai::agent::api::{self, RequestParams};
|
use crate::ai::agent::api::{self, RequestParams};
|
||||||
use crate::ai::agent::EntrypointType;
|
use crate::ai::agent::EntrypointType;
|
||||||
@@ -88,7 +88,15 @@ pub(crate) async fn acp_output_stream(
|
|||||||
if let Some(server) = galaxy_mcp_server {
|
if let Some(server) = galaxy_mcp_server {
|
||||||
mcp_servers.push(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 =
|
let mut runtime_config =
|
||||||
AcpAgentRuntimeConfig::new(runtime_id.clone(), backend.agent_id.clone(), cwd);
|
AcpAgentRuntimeConfig::new(runtime_id.clone(), backend.agent_id.clone(), cwd);
|
||||||
runtime_config.config_values = backend
|
runtime_config.config_values = backend
|
||||||
@@ -184,7 +192,15 @@ fn response_translator(
|
|||||||
conversation_id: String::new(),
|
conversation_id: String::new(),
|
||||||
needs_create_task: params.tasks.is_empty(),
|
needs_create_task: params.tasks.is_empty(),
|
||||||
user_query,
|
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,
|
max_context_tokens: None,
|
||||||
capabilities: RuntimeCapabilities::session_runtime(),
|
capabilities: RuntimeCapabilities::session_runtime(),
|
||||||
empty_output_message: Some("> ACP agent completed without a text response.".to_owned()),
|
empty_output_message: Some("> ACP agent completed without a text response.".to_owned()),
|
||||||
|
|||||||
@@ -228,6 +228,7 @@ fn acp_session_id_is_updated_only_for_acp_conversations() {
|
|||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
AgentBackend::Acp(AcpConversationData {
|
AgentBackend::Acp(AcpConversationData {
|
||||||
|
provider_id: "provider-1".to_string(),
|
||||||
agent_id: "codex-acp".to_string(),
|
agent_id: "codex-acp".to_string(),
|
||||||
launch_fingerprint: "launch-123".to_string(),
|
launch_fingerprint: "launch-123".to_string(),
|
||||||
session_id: None,
|
session_id: None,
|
||||||
@@ -238,6 +239,7 @@ fn acp_session_id_is_updated_only_for_acp_conversations() {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
acp_conversation.agent_backend(),
|
acp_conversation.agent_backend(),
|
||||||
&AgentBackend::Acp(AcpConversationData {
|
&AgentBackend::Acp(AcpConversationData {
|
||||||
|
provider_id: "provider-1".to_string(),
|
||||||
agent_id: "codex-acp".to_string(),
|
agent_id: "codex-acp".to_string(),
|
||||||
launch_fingerprint: "launch-123".to_string(),
|
launch_fingerprint: "launch-123".to_string(),
|
||||||
session_id: Some("session-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]
|
#[test]
|
||||||
fn restored_conversation_uses_persisted_acp_backend() {
|
fn restored_conversation_uses_persisted_acp_backend() {
|
||||||
let backend = AgentBackend::Acp(AcpConversationData {
|
let backend = AgentBackend::Acp(AcpConversationData {
|
||||||
|
provider_id: "provider-1".to_string(),
|
||||||
agent_id: "codex-acp".to_string(),
|
agent_id: "codex-acp".to_string(),
|
||||||
launch_fingerprint: "launch-123".to_string(),
|
launch_fingerprint: "launch-123".to_string(),
|
||||||
session_id: Some("session-123".to_string()),
|
session_id: Some("session-123".to_string()),
|
||||||
|
|||||||
@@ -56,10 +56,21 @@ use crate::ai::agent::{
|
|||||||
CancellationOutcome, CancellationReason, CreateDocumentsResult, EditDocumentsResult,
|
CancellationOutcome, CancellationReason, CreateDocumentsResult, EditDocumentsResult,
|
||||||
RequestCommandOutputResult,
|
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::ai_document_view::DEFAULT_PLANNING_DOCUMENT_TITLE;
|
||||||
use crate::ai::blocklist::action_model::execute::suggest_new_conversation::SuggestNewConversationExecutor;
|
use crate::ai::blocklist::action_model::execute::suggest_new_conversation::SuggestNewConversationExecutor;
|
||||||
use crate::ai::document::ai_document_model::AIDocumentModel;
|
use crate::ai::document::ai_document_model::AIDocumentModel;
|
||||||
use crate::ai::get_relevant_files::controller::GetRelevantFilesController;
|
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::session::active_session::ActiveSession;
|
||||||
use crate::terminal::model_events::ModelEventDispatcher;
|
use crate::terminal::model_events::ModelEventDispatcher;
|
||||||
use crate::terminal::TerminalModel;
|
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 {
|
pub struct BlocklistAIActionModel {
|
||||||
executor: ModelHandle<BlocklistAIActionExecutor>,
|
executor: ModelHandle<BlocklistAIActionExecutor>,
|
||||||
|
|
||||||
@@ -927,6 +1206,21 @@ impl BlocklistAIActionModel {
|
|||||||
ctx: &mut ModelContext<Self>,
|
ctx: &mut ModelContext<Self>,
|
||||||
) {
|
) {
|
||||||
if reason.needs_confirmation() {
|
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(
|
ctx.emit(BlocklistAIActionEvent::ActionBlockedOnUserConfirmation(
|
||||||
action.id.clone(),
|
action.id.clone(),
|
||||||
));
|
));
|
||||||
@@ -1005,7 +1299,27 @@ impl BlocklistAIActionModel {
|
|||||||
|
|
||||||
let action_id = action.id.clone();
|
let action_id = action.id.clone();
|
||||||
let phase = self.action_phase_for_action(&action, ctx);
|
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 {
|
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 {
|
ctx.emit(BlocklistAIActionEvent::ToolLifecycle {
|
||||||
action_id: action_id.clone(),
|
action_id: action_id.clone(),
|
||||||
event: ToolEvent::PermissionResolved {
|
event: ToolEvent::PermissionResolved {
|
||||||
@@ -1024,6 +1338,17 @@ impl BlocklistAIActionModel {
|
|||||||
|
|
||||||
match execute_result {
|
match execute_result {
|
||||||
TryExecuteResult::ExecutedAsync => {
|
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 {
|
if !is_wait_for_events {
|
||||||
self.update_conversation_in_progress_status(conversation_id, ctx);
|
self.update_conversation_in_progress_status(conversation_id, ctx);
|
||||||
}
|
}
|
||||||
@@ -1031,6 +1356,17 @@ impl BlocklistAIActionModel {
|
|||||||
Some(StartedAction::Async { phase })
|
Some(StartedAction::Async { phase })
|
||||||
}
|
}
|
||||||
TryExecuteResult::ExecutedSync => {
|
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 {
|
if !is_wait_for_events {
|
||||||
self.update_conversation_in_progress_status(conversation_id, ctx);
|
self.update_conversation_in_progress_status(conversation_id, ctx);
|
||||||
}
|
}
|
||||||
@@ -1079,6 +1415,29 @@ impl BlocklistAIActionModel {
|
|||||||
std::mem::discriminant(&action.action)
|
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(
|
self.action_order.insert(
|
||||||
conversation_id,
|
conversation_id,
|
||||||
actions
|
actions
|
||||||
@@ -1309,6 +1668,21 @@ impl BlocklistAIActionModel {
|
|||||||
if permission_denied {
|
if permission_denied {
|
||||||
self.denied_permissions
|
self.denied_permissions
|
||||||
.insert((conversation_id, pending_action.id.clone()));
|
.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 {
|
ctx.emit(BlocklistAIActionEvent::ToolLifecycle {
|
||||||
action_id: pending_action.id.clone(),
|
action_id: pending_action.id.clone(),
|
||||||
event: ToolEvent::PermissionResolved {
|
event: ToolEvent::PermissionResolved {
|
||||||
@@ -1476,6 +1850,32 @@ impl BlocklistAIActionModel {
|
|||||||
.entry(conversation_id)
|
.entry(conversation_id)
|
||||||
.or_default()
|
.or_default()
|
||||||
.push(tool_result.clone());
|
.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 {
|
ctx.emit(BlocklistAIActionEvent::ToolLifecycle {
|
||||||
action_id: action_result.id.clone(),
|
action_id: action_result.id.clone(),
|
||||||
event: ToolEvent::Completed {
|
event: ToolEvent::Completed {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ use itertools::Itertools;
|
|||||||
use parking_lot::FairMutex;
|
use parking_lot::FairMutex;
|
||||||
use pending_response_streams::PendingResponseStreams;
|
use pending_response_streams::PendingResponseStreams;
|
||||||
use session_sharing_protocol::common::ParticipantId;
|
use session_sharing_protocol::common::ParticipantId;
|
||||||
|
use settings::Setting;
|
||||||
pub use slash_command::*;
|
pub use slash_command::*;
|
||||||
use warp_multi_agent_api::{message, Task, ToolType};
|
use warp_multi_agent_api::{message, Task, ToolType};
|
||||||
use warpui::r#async::{SpawnedFutureHandle, Timer};
|
use warpui::r#async::{SpawnedFutureHandle, Timer};
|
||||||
@@ -220,9 +221,18 @@ enum RunningCommandDetection {
|
|||||||
fn acp_backend_model_id(backend: &AgentBackend) -> Option<LLMId> {
|
fn acp_backend_model_id(backend: &AgentBackend) -> Option<LLMId> {
|
||||||
match backend {
|
match backend {
|
||||||
AgentBackend::Provider => None,
|
AgentBackend::Provider => None,
|
||||||
AgentBackend::Acp(acp) => {
|
AgentBackend::Acp(acp) => Some(
|
||||||
Some(crate::ai::acp::acp_selection_identity(&acp.agent_id, &acp.config_values).into())
|
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| {
|
.map(|conversation| {
|
||||||
(
|
(
|
||||||
match conversation.agent_backend() {
|
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,
|
AgentBackend::Provider => None,
|
||||||
},
|
},
|
||||||
conversation
|
conversation
|
||||||
@@ -3853,10 +3865,29 @@ impl BlocklistAIController {
|
|||||||
#[cfg(not(target_family = "wasm"))]
|
#[cfg(not(target_family = "wasm"))]
|
||||||
if let Some(metadata) = response_stream.as_ref(ctx).acp_session_metadata() {
|
if let Some(metadata) = response_stream.as_ref(ctx).acp_session_metadata() {
|
||||||
if !metadata.config_options.is_empty() {
|
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(
|
crate::settings::AISettings::handle(ctx).update(
|
||||||
ctx,
|
ctx,
|
||||||
|settings, 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) =
|
if let Err(error) =
|
||||||
crate::ai::acp::AcpRuntimeModel::persist_runtime_options(
|
crate::ai::acp::AcpRuntimeModel::persist_runtime_options(
|
||||||
settings,
|
settings,
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ use crate::ai::blocklist::BlocklistAIPermissions;
|
|||||||
use crate::ai::llms::{LLMId, LLMPreferences};
|
use crate::ai::llms::{LLMId, LLMPreferences};
|
||||||
use crate::ai::openai::client::OpenAIClientConfig;
|
use crate::ai::openai::client::OpenAIClientConfig;
|
||||||
use crate::ai::provider::ProviderConfig;
|
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::ai::runtime::ProviderRuntime;
|
||||||
use crate::network::NetworkStatus;
|
use crate::network::NetworkStatus;
|
||||||
#[cfg(not(target_family = "wasm"))]
|
#[cfg(not(target_family = "wasm"))]
|
||||||
@@ -142,6 +144,10 @@ pub struct ResponseStream {
|
|||||||
has_received_client_actions: bool,
|
has_received_client_actions: bool,
|
||||||
/// AI identifiers for telemetry emission
|
/// AI identifiers for telemetry emission
|
||||||
ai_identifiers: AIIdentifiers,
|
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.
|
/// 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
|
/// 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,
|
original_error: None,
|
||||||
has_received_client_actions: false,
|
has_received_client_actions: false,
|
||||||
ai_identifiers: AIIdentifiers::default(),
|
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,
|
can_attempt_resume_on_error: false,
|
||||||
should_resume_conversation_after_stream_finished: false,
|
should_resume_conversation_after_stream_finished: false,
|
||||||
stream_finished_received: false,
|
stream_finished_received: false,
|
||||||
@@ -292,6 +302,250 @@ impl ResponseStream {
|
|||||||
ProviderConfig::None
|
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"))]
|
#[cfg(not(target_family = "wasm"))]
|
||||||
fn resolve_acp_manager(
|
fn resolve_acp_manager(
|
||||||
backend: &crate::persistence::model::AcpConversationData,
|
backend: &crate::persistence::model::AcpConversationData,
|
||||||
@@ -300,23 +554,23 @@ impl ResponseStream {
|
|||||||
use galaxy_acp::AcpManagerConfig;
|
use galaxy_acp::AcpManagerConfig;
|
||||||
|
|
||||||
let settings = AISettings::as_ref(ctx);
|
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() {
|
let configured_agent_id = if configured_agent_id.is_empty() {
|
||||||
"codex"
|
"codex"
|
||||||
} else {
|
} else {
|
||||||
configured_agent_id
|
configured_agent_id
|
||||||
};
|
};
|
||||||
let launch = resolve_acp_launch(
|
let launch = resolve_acp_launch(configured_agent_id, &provider.command, &provider.args)?;
|
||||||
configured_agent_id,
|
validate_acp_launch_identity(backend, configured_agent_id, &provider.command, &launch)?;
|
||||||
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 config = AcpManagerConfig::new(launch);
|
let config = AcpManagerConfig::new(launch);
|
||||||
AcpRuntimeModel::handle(ctx).update(ctx, |runtime, _| runtime.manager(config))
|
AcpRuntimeModel::handle(ctx).update(ctx, |runtime, _| runtime.manager(config))
|
||||||
}
|
}
|
||||||
@@ -447,6 +701,7 @@ impl ResponseStream {
|
|||||||
let start_time = Local::now();
|
let start_time = Local::now();
|
||||||
|
|
||||||
let request_id = Uuid::new_v4();
|
let request_id = Uuid::new_v4();
|
||||||
|
let response_stream_id = ResponseStreamId(Uuid::new_v4().to_string());
|
||||||
let runtime_capabilities = match &agent_backend {
|
let runtime_capabilities = match &agent_backend {
|
||||||
AgentBackend::Provider => RuntimeCapabilities::provider(),
|
AgentBackend::Provider => RuntimeCapabilities::provider(),
|
||||||
AgentBackend::Acp(_) => RuntimeCapabilities::session_runtime(),
|
AgentBackend::Acp(_) => RuntimeCapabilities::session_runtime(),
|
||||||
@@ -455,9 +710,28 @@ impl ResponseStream {
|
|||||||
let acp_session_metadata = Arc::new(Mutex::new(AcpSessionMetadata::default()));
|
let acp_session_metadata = Arc::new(Mutex::new(AcpSessionMetadata::default()));
|
||||||
#[cfg(not(target_family = "wasm"))]
|
#[cfg(not(target_family = "wasm"))]
|
||||||
let acp_turn_control = Arc::new(Mutex::new(None));
|
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 {
|
match &agent_backend {
|
||||||
AgentBackend::Provider => {
|
AgentBackend::Provider => {
|
||||||
let provider_config = Self::resolve_provider_config(params.model.as_str(), ctx);
|
let provider_config = Self::resolve_provider_config(params.model.as_str(), ctx);
|
||||||
|
#[cfg(not(target_family = "wasm"))]
|
||||||
|
{
|
||||||
|
remote_log_backend = "provider".to_string();
|
||||||
|
remote_log_provider = Self::remote_log_provider_for_config(&provider_config);
|
||||||
|
Self::log_llm_request_started(
|
||||||
|
ctx,
|
||||||
|
&response_stream_id,
|
||||||
|
request_id,
|
||||||
|
¶ms,
|
||||||
|
&ai_identifiers,
|
||||||
|
&remote_log_backend,
|
||||||
|
&remote_log_provider,
|
||||||
|
can_attempt_resume_on_error,
|
||||||
|
);
|
||||||
|
}
|
||||||
Self::spawn_provider_request(
|
Self::spawn_provider_request(
|
||||||
params.clone(),
|
params.clone(),
|
||||||
provider_config,
|
provider_config,
|
||||||
@@ -467,6 +741,25 @@ impl ResponseStream {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
AgentBackend::Acp(backend) => {
|
AgentBackend::Acp(backend) => {
|
||||||
|
#[cfg(not(target_family = "wasm"))]
|
||||||
|
{
|
||||||
|
remote_log_backend = "acp".to_string();
|
||||||
|
remote_log_provider = if backend.agent_id.is_empty() {
|
||||||
|
"acp".to_string()
|
||||||
|
} else {
|
||||||
|
format!("acp:{}", backend.agent_id)
|
||||||
|
};
|
||||||
|
Self::log_llm_request_started(
|
||||||
|
ctx,
|
||||||
|
&response_stream_id,
|
||||||
|
request_id,
|
||||||
|
¶ms,
|
||||||
|
&ai_identifiers,
|
||||||
|
&remote_log_backend,
|
||||||
|
&remote_log_provider,
|
||||||
|
can_attempt_resume_on_error,
|
||||||
|
);
|
||||||
|
}
|
||||||
#[cfg(not(target_family = "wasm"))]
|
#[cfg(not(target_family = "wasm"))]
|
||||||
Self::spawn_acp_request(
|
Self::spawn_acp_request(
|
||||||
backend.clone(),
|
backend.clone(),
|
||||||
@@ -500,7 +793,7 @@ impl ResponseStream {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Self {
|
Self {
|
||||||
id: ResponseStreamId(Uuid::new_v4().to_string()),
|
id: response_stream_id,
|
||||||
runtime_capabilities,
|
runtime_capabilities,
|
||||||
#[cfg(not(target_family = "wasm"))]
|
#[cfg(not(target_family = "wasm"))]
|
||||||
acp_session_metadata,
|
acp_session_metadata,
|
||||||
@@ -515,6 +808,10 @@ impl ResponseStream {
|
|||||||
original_error: None,
|
original_error: None,
|
||||||
has_received_client_actions: false,
|
has_received_client_actions: false,
|
||||||
ai_identifiers,
|
ai_identifiers,
|
||||||
|
#[cfg(not(target_family = "wasm"))]
|
||||||
|
remote_log_backend,
|
||||||
|
#[cfg(not(target_family = "wasm"))]
|
||||||
|
remote_log_provider,
|
||||||
can_attempt_resume_on_error,
|
can_attempt_resume_on_error,
|
||||||
should_resume_conversation_after_stream_finished: false,
|
should_resume_conversation_after_stream_finished: false,
|
||||||
stream_finished_received: false,
|
stream_finished_received: false,
|
||||||
@@ -640,6 +937,16 @@ impl ResponseStream {
|
|||||||
|
|
||||||
let request_id = Uuid::new_v4();
|
let request_id = Uuid::new_v4();
|
||||||
self.current_request_id = Some(request_id);
|
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 params = self.params.clone();
|
||||||
let provider_config = Self::resolve_provider_config(params.model.as_str(), ctx);
|
let provider_config = Self::resolve_provider_config(params.model.as_str(), ctx);
|
||||||
let _ = ctx.spawn(
|
let _ = ctx.spawn(
|
||||||
@@ -675,6 +982,18 @@ impl ResponseStream {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn retry_with_coding_model(&mut self, ctx: &mut ModelContext<Self>) {
|
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.coding_model_fallback_attempted = true;
|
||||||
self.params.model = self.params.coding_model.clone();
|
self.params.model = self.params.coding_model.clone();
|
||||||
self.retry(ctx);
|
self.retry(ctx);
|
||||||
@@ -730,6 +1049,13 @@ impl ResponseStream {
|
|||||||
// terminally. (HTTP send failures don't take this path — they arrive as
|
// terminally. (HTTP send failures don't take this path — they arrive as
|
||||||
// in-stream error events.)
|
// in-stream error events.)
|
||||||
let error = Arc::new(AIApiError::Other(anyhow!(e)));
|
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.error_event_emitted = true;
|
||||||
self.report_request_failure(&error, NetworkStatus::as_ref(ctx).is_online());
|
self.report_request_failure(&error, NetworkStatus::as_ref(ctx).is_online());
|
||||||
ctx.emit(ResponseStreamEvent::ReceivedEvent(Consumable::new(Err(
|
ctx.emit(ResponseStreamEvent::ReceivedEvent(Consumable::new(Err(
|
||||||
@@ -762,6 +1088,42 @@ impl ResponseStream {
|
|||||||
action.id,
|
action.id,
|
||||||
action.task_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)));
|
ctx.emit(ResponseStreamEvent::ReceivedEvent(Consumable::new(event)));
|
||||||
}
|
}
|
||||||
Ok(api::StreamEvent::Response(response_event)) => {
|
Ok(api::StreamEvent::Response(response_event)) => {
|
||||||
@@ -787,6 +1149,13 @@ impl ResponseStream {
|
|||||||
None => "None",
|
None => "None",
|
||||||
};
|
};
|
||||||
log::info!("[bedrock-debug] ResponseStream emitting event type={event_type_name}");
|
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 {
|
if let Some(event_type) = &response_event.r#type {
|
||||||
match event_type {
|
match event_type {
|
||||||
warp_multi_agent_api::response_event::Type::Init(init_event) => {
|
warp_multi_agent_api::response_event::Type::Init(init_event) => {
|
||||||
@@ -796,12 +1165,35 @@ impl ResponseStream {
|
|||||||
init_event.request_id.clone(),
|
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
|
// Mark that we've received client actions
|
||||||
self.has_received_client_actions = true;
|
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) => {
|
warp_multi_agent_api::response_event::Type::Finished(finished_event) => {
|
||||||
self.stream_finished_received = true;
|
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
|
// Emit retry success telemetry on successful completion
|
||||||
if matches!(
|
if matches!(
|
||||||
finished_event.reason,
|
finished_event.reason,
|
||||||
@@ -837,6 +1229,13 @@ impl ResponseStream {
|
|||||||
log::warn!(
|
log::warn!(
|
||||||
"Thinking model rate-limited; retrying with the profile coding model"
|
"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);
|
self.retry_with_coding_model(ctx);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -855,6 +1254,8 @@ impl ResponseStream {
|
|||||||
self.retry_count + 1,
|
self.retry_count + 1,
|
||||||
MAX_RETRIES
|
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.
|
// Only emit error telemetry here if we're retrying.
|
||||||
// Final errors that aren't being retried are emitted elsewhere.
|
// Final errors that aren't being retried are emitted elsewhere.
|
||||||
self.emit_retryable_agent_mode_error_telemetry(format!("{e:?}"), ctx);
|
self.emit_retryable_agent_mode_error_telemetry(format!("{e:?}"), ctx);
|
||||||
@@ -868,6 +1269,13 @@ impl ResponseStream {
|
|||||||
self.retry_count + 1,
|
self.retry_count + 1,
|
||||||
MAX_RETRIES
|
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.emit_retryable_agent_mode_error_telemetry(format!("{e:?}"), ctx);
|
||||||
self.defer_retry_until_online(ctx);
|
self.defer_retry_until_online(ctx);
|
||||||
return;
|
return;
|
||||||
@@ -880,10 +1288,20 @@ impl ResponseStream {
|
|||||||
log::warn!(
|
log::warn!(
|
||||||
"MultiAgent request failed after client actions; resuming conversation after stream finishes - Error: {e:?}"
|
"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.
|
// The resume spawn itself waits for connectivity.
|
||||||
self.should_resume_conversation_after_stream_finished = true;
|
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;
|
self.error_event_emitted = true;
|
||||||
|
|
||||||
@@ -928,6 +1346,13 @@ impl ResponseStream {
|
|||||||
self.retry_count + 1,
|
self.retry_count + 1,
|
||||||
MAX_RETRIES
|
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(
|
self.emit_retryable_agent_mode_error_telemetry(
|
||||||
format!("{unexpected_eof:?}"),
|
format!("{unexpected_eof:?}"),
|
||||||
ctx,
|
ctx,
|
||||||
@@ -941,6 +1366,13 @@ impl ResponseStream {
|
|||||||
self.retry_count + 1,
|
self.retry_count + 1,
|
||||||
MAX_RETRIES
|
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(
|
self.emit_retryable_agent_mode_error_telemetry(
|
||||||
format!("{unexpected_eof:?}"),
|
format!("{unexpected_eof:?}"),
|
||||||
ctx,
|
ctx,
|
||||||
@@ -956,6 +1388,13 @@ impl ResponseStream {
|
|||||||
log::warn!(
|
log::warn!(
|
||||||
"MultiAgent request truncated after client actions; resuming conversation after stream finishes - Error: {unexpected_eof:?}"
|
"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.should_resume_conversation_after_stream_finished = true;
|
||||||
self.error_event_emitted = true;
|
self.error_event_emitted = true;
|
||||||
self.report_request_failure(&unexpected_eof, is_online);
|
self.report_request_failure(&unexpected_eof, is_online);
|
||||||
@@ -964,6 +1403,8 @@ impl ResponseStream {
|
|||||||
))));
|
))));
|
||||||
}
|
}
|
||||||
RecoveryAction::Fail => {
|
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.error_event_emitted = true;
|
||||||
self.report_request_failure(&unexpected_eof, is_online);
|
self.report_request_failure(&unexpected_eof, is_online);
|
||||||
ctx.emit(ResponseStreamEvent::ReceivedEvent(Consumable::new(Err(
|
ctx.emit(ResponseStreamEvent::ReceivedEvent(Consumable::new(Err(
|
||||||
@@ -1031,6 +1472,118 @@ impl ResponseStream {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_family = "wasm"))]
|
||||||
|
fn raw_model_request_payload(params: &api::RequestParams) -> String {
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"model_id": params.model.as_str(),
|
||||||
|
"coding_model_id": params.coding_model.as_str(),
|
||||||
|
"cli_agent_model_id": params.cli_agent_model.as_str(),
|
||||||
|
"computer_use_model_id": params.computer_use_model.as_str(),
|
||||||
|
"input": format!("{:#?}", params.input),
|
||||||
|
"tool_results": format!("{:#?}", params.tool_results),
|
||||||
|
"tasks": format!("{:#?}", params.tasks),
|
||||||
|
"message_history": serde_json::to_value(¶ms.message_history)
|
||||||
|
.unwrap_or_else(|_| serde_json::json!(format!("{:#?}", params.message_history))),
|
||||||
|
"progressive_summary": ¶ms.progressive_summary,
|
||||||
|
"tool_result_archive": serde_json::to_value(¶ms.tool_result_archive)
|
||||||
|
.unwrap_or_else(|_| serde_json::json!(format!("{:#?}", params.tool_result_archive))),
|
||||||
|
"global_rules": ¶ms.global_rules,
|
||||||
|
"mcp_context": format!("{:#?}", params.mcp_context),
|
||||||
|
"session": {
|
||||||
|
"is_remote": params.session_context.is_remote(),
|
||||||
|
},
|
||||||
|
"features": {
|
||||||
|
"memory_enabled": params.is_memory_enabled,
|
||||||
|
"warp_drive_context_enabled": params.warp_drive_context_enabled,
|
||||||
|
"planning_enabled": params.planning_enabled,
|
||||||
|
"web_search_enabled": params.web_search_enabled,
|
||||||
|
"computer_use_enabled": params.computer_use_enabled,
|
||||||
|
"ask_user_question_enabled": params.ask_user_question_enabled,
|
||||||
|
"research_agent_enabled": params.research_agent_enabled,
|
||||||
|
"orchestration_enabled": params.orchestration_enabled,
|
||||||
|
},
|
||||||
|
"autonomy_level": format!("{:?}", params.autonomy_level),
|
||||||
|
"isolation_level": format!("{:?}", params.isolation_level),
|
||||||
|
"supported_tools_override": format!("{:#?}", params.supported_tools_override),
|
||||||
|
"context_window_limit": params.context_window_limit,
|
||||||
|
"omitted_sensitive_fields": [
|
||||||
|
"api_keys",
|
||||||
|
"custom_model_providers",
|
||||||
|
"custom_model_routers",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
serde_json::to_string_pretty(&payload).unwrap_or_else(|_| format!("{payload:#?}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_family = "wasm"))]
|
||||||
|
fn action_tool_name(action: &crate::ai::agent::AIAgentAction) -> String {
|
||||||
|
action.tool_name.clone().unwrap_or_else(|| {
|
||||||
|
format!(
|
||||||
|
"{:?}",
|
||||||
|
crate::ai::agent::AIAgentActionTypeDiscriminants::from(&action.action)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_family = "wasm"))]
|
||||||
|
fn stream_finished_reason_name(
|
||||||
|
reason: &Option<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"))]
|
#[cfg(not(target_family = "wasm"))]
|
||||||
fn is_interactive_remote_command(command: &str) -> bool {
|
fn is_interactive_remote_command(command: &str) -> bool {
|
||||||
is_potential_remote_ssh_command(command)
|
is_potential_remote_ssh_command(command)
|
||||||
|
|||||||
@@ -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::Provider), None);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
super::acp_backend_model_id(&AgentBackend::Acp(AcpConversationData {
|
super::acp_backend_model_id(&AgentBackend::Acp(AcpConversationData {
|
||||||
|
provider_id: String::new(),
|
||||||
agent_id: " Codex ".to_owned(),
|
agent_id: " Codex ".to_owned(),
|
||||||
launch_fingerprint: "launch-123".to_owned(),
|
launch_fingerprint: "launch-123".to_owned(),
|
||||||
session_id: None,
|
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\""))
|
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]
|
#[test]
|
||||||
|
|||||||
@@ -1206,33 +1206,29 @@ impl BlocklistAIHistoryModel {
|
|||||||
return llm_preferences.agent_backend_for_active_model(Some(terminal_surface_id), ctx);
|
return llm_preferences.agent_backend_for_active_model(Some(terminal_surface_id), ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
let configured_agent_id = settings.acp_agent_id.value().trim();
|
let providers = settings.enabled_acp_providers();
|
||||||
let agent_id = if configured_agent_id.is_empty() {
|
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"
|
"codex"
|
||||||
} else {
|
} else {
|
||||||
configured_agent_id
|
agent_id
|
||||||
};
|
};
|
||||||
#[cfg(not(target_family = "wasm"))]
|
#[cfg(not(target_family = "wasm"))]
|
||||||
let launch_fingerprint = acp_launch_fingerprint(
|
let launch_fingerprint =
|
||||||
agent_id,
|
acp_launch_fingerprint(agent_id, &provider.command, &provider.args);
|
||||||
settings.acp_agent_command.value(),
|
|
||||||
settings.acp_agent_args.value(),
|
|
||||||
);
|
|
||||||
#[cfg(target_family = "wasm")]
|
#[cfg(target_family = "wasm")]
|
||||||
let launch_fingerprint = String::new();
|
let launch_fingerprint = String::new();
|
||||||
AgentBackend::Acp(AcpConversationData {
|
AgentBackend::Acp(AcpConversationData {
|
||||||
|
provider_id: provider.id.clone(),
|
||||||
agent_id: agent_id.to_string(),
|
agent_id: agent_id.to_string(),
|
||||||
launch_fingerprint,
|
launch_fingerprint,
|
||||||
session_id: None,
|
session_id: None,
|
||||||
config_values: settings
|
config_values: crate::ai::acp::AcpRuntimeModel::current_config_values(
|
||||||
.acp_agents
|
&provider.config_options,
|
||||||
.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(),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ fn acp_enabled_with_empty_command_selects_codex_backend() {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
conversation.agent_backend(),
|
conversation.agent_backend(),
|
||||||
&AgentBackend::Acp(AcpConversationData {
|
&AgentBackend::Acp(AcpConversationData {
|
||||||
|
provider_id: "legacy".to_string(),
|
||||||
agent_id: "codex".to_string(),
|
agent_id: "codex".to_string(),
|
||||||
launch_fingerprint: crate::ai::acp::acp_launch_fingerprint("codex", "", &[]),
|
launch_fingerprint: crate::ai::acp::acp_launch_fingerprint("codex", "", &[]),
|
||||||
session_id: None,
|
session_id: None,
|
||||||
|
|||||||
@@ -183,9 +183,15 @@ fn chatgpt_redirect_uri() -> String {
|
|||||||
format!("{}://chatgpt/oauth2callback", ChannelState::url_scheme())
|
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.
|
/// 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.
|
/// 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 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 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}"))?;
|
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)
|
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> {
|
fn codex_auth_file_path() -> Option<std::path::PathBuf> {
|
||||||
if let Some(codex_home) = std::env::var_os("CODEX_HOME") {
|
if let Some(codex_home) = std::env::var_os("CODEX_HOME") {
|
||||||
return Some(std::path::PathBuf::from(codex_home).join("auth.json"));
|
return Some(std::path::PathBuf::from(codex_home).join("auth.json"));
|
||||||
@@ -382,7 +437,7 @@ struct TokenResponse {
|
|||||||
id_token: Option<String>,
|
id_token: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(serde::Serialize)]
|
#[derive(serde::Deserialize, serde::Serialize)]
|
||||||
struct AuthRecord {
|
struct AuthRecord {
|
||||||
access_token: Option<String>,
|
access_token: Option<String>,
|
||||||
refresh_token: Option<String>,
|
refresh_token: Option<String>,
|
||||||
|
|||||||
+435
-79
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||||
use std::sync::{Arc, OnceLock};
|
use std::sync::{Arc, OnceLock};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use ai::api_keys::ApiKeyManager;
|
use ai::api_keys::ApiKeyManager;
|
||||||
pub use ai::LLMId;
|
pub use ai::LLMId;
|
||||||
@@ -13,6 +14,7 @@ use galaxy_agent_rig::{
|
|||||||
use galaxy_core::features::FeatureFlag;
|
use galaxy_core::features::FeatureFlag;
|
||||||
use galaxy_core::ui::icons::Icon;
|
use galaxy_core::ui::icons::Icon;
|
||||||
use galaxy_core::user_preferences::GetUserPreferences;
|
use galaxy_core::user_preferences::GetUserPreferences;
|
||||||
|
use galaxyui::r#async::Timer;
|
||||||
use galaxyui::{AppContext, Entity, EntityId, ModelContext, SingletonEntity};
|
use galaxyui::{AppContext, Entity, EntityId, ModelContext, SingletonEntity};
|
||||||
use parking_lot::FairMutex;
|
use parking_lot::FairMutex;
|
||||||
use serde::{de, Deserialize, Serialize};
|
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::custom_model_routers::{self, CustomModelRouter, ModelConfigError};
|
||||||
use super::execution_profiles::profiles::AIExecutionProfilesModel;
|
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::auth_manager::{AuthManager, AuthManagerEvent};
|
||||||
use crate::auth::AuthStateProvider;
|
use crate::auth::AuthStateProvider;
|
||||||
use crate::network::{NetworkStatus, NetworkStatusEvent, NetworkStatusKind};
|
use crate::network::{NetworkStatus, NetworkStatusEvent, NetworkStatusKind};
|
||||||
@@ -29,8 +31,8 @@ use crate::network::{NetworkStatus, NetworkStatusEvent, NetworkStatusKind};
|
|||||||
use crate::persistence::model::{AcpConversationData, AgentBackend};
|
use crate::persistence::model::{AcpConversationData, AgentBackend};
|
||||||
use crate::server::server_api::ServerApiProvider;
|
use crate::server::server_api::ServerApiProvider;
|
||||||
use crate::settings::{
|
use crate::settings::{
|
||||||
AcpConfigValueSettings, BedrockModelConfig, OpenAIModelConfig, OpenAIProviderConfig,
|
AcpConfigValueSettings, AcpProviderConfig, BedrockModelConfig, OpenAIModelConfig,
|
||||||
OpenAIProviderKind,
|
OpenAIProviderConfig, OpenAIProviderKind,
|
||||||
};
|
};
|
||||||
use crate::user_config::{WarpConfig, WarpConfigUpdateEvent};
|
use crate::user_config::{WarpConfig, WarpConfigUpdateEvent};
|
||||||
use crate::workspaces::user_workspaces::{UserWorkspaces, UserWorkspacesEvent};
|
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`].
|
/// but was migrated to store a full [`ModelsByFeature`].
|
||||||
pub const MODELS_BY_FEATURE_CACHE_KEY: &str = "AvailableLLMs";
|
pub const MODELS_BY_FEATURE_CACHE_KEY: &str = "AvailableLLMs";
|
||||||
const CUSTOM_ENDPOINT_USAGE_FALLBACK_LABEL: &str = "Custom endpoint";
|
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)]
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct LLMUsageMetadata {
|
pub struct LLMUsageMetadata {
|
||||||
@@ -591,13 +597,17 @@ pub struct LLMPreferences {
|
|||||||
#[cfg(not(target_family = "wasm"))]
|
#[cfg(not(target_family = "wasm"))]
|
||||||
fetched_openai_models: Vec<OpenAIModelConfig>,
|
fetched_openai_models: Vec<OpenAIModelConfig>,
|
||||||
#[cfg(not(target_family = "wasm"))]
|
#[cfg(not(target_family = "wasm"))]
|
||||||
|
chatgpt_subscription_models_refresh_in_flight: bool,
|
||||||
|
#[cfg(not(target_family = "wasm"))]
|
||||||
acp_selections: HashMap<LLMId, AcpModelSelection>,
|
acp_selections: HashMap<LLMId, AcpModelSelection>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(target_family = "wasm"))]
|
#[cfg(not(target_family = "wasm"))]
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
pub(crate) struct AcpModelSelection {
|
pub(crate) struct AcpModelSelection {
|
||||||
|
pub(crate) provider_id: String,
|
||||||
pub(crate) agent_id: String,
|
pub(crate) agent_id: String,
|
||||||
|
pub(crate) launch_fingerprint: String,
|
||||||
pub(crate) config_values: BTreeMap<String, serde_json::Value>,
|
pub(crate) config_values: BTreeMap<String, serde_json::Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -611,6 +621,7 @@ impl LLMPreferences {
|
|||||||
} = event
|
} = event
|
||||||
{
|
{
|
||||||
me.refresh_authed_models(ctx);
|
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| {
|
ctx.subscribe_to_model(&AuthManager::handle(ctx), |me, _, event, ctx| {
|
||||||
if let AuthManagerEvent::AuthComplete = event {
|
if let AuthManagerEvent::AuthComplete = event {
|
||||||
me.refresh_authed_models(ctx);
|
me.refresh_authed_models(ctx);
|
||||||
|
me.refresh_chatgpt_subscription_models(ctx);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -664,6 +676,7 @@ impl LLMPreferences {
|
|||||||
| AISettingsChangedEvent::OpenAIApiKey { .. }
|
| AISettingsChangedEvent::OpenAIApiKey { .. }
|
||||||
| AISettingsChangedEvent::OpenAIModels { .. }
|
| AISettingsChangedEvent::OpenAIModels { .. }
|
||||||
| AISettingsChangedEvent::OpenAIProviders { .. }
|
| AISettingsChangedEvent::OpenAIProviders { .. }
|
||||||
|
| AISettingsChangedEvent::AcpProviders { .. }
|
||||||
| AISettingsChangedEvent::AcpAgents { .. }
|
| AISettingsChangedEvent::AcpAgents { .. }
|
||||||
| AISettingsChangedEvent::AcpAgentId { .. }
|
| AISettingsChangedEvent::AcpAgentId { .. }
|
||||||
| AISettingsChangedEvent::BedrockModels { .. }
|
| AISettingsChangedEvent::BedrockModels { .. }
|
||||||
@@ -704,6 +717,8 @@ impl LLMPreferences {
|
|||||||
#[cfg(not(target_family = "wasm"))]
|
#[cfg(not(target_family = "wasm"))]
|
||||||
fetched_openai_models: Vec::new(),
|
fetched_openai_models: Vec::new(),
|
||||||
#[cfg(not(target_family = "wasm"))]
|
#[cfg(not(target_family = "wasm"))]
|
||||||
|
chatgpt_subscription_models_refresh_in_flight: false,
|
||||||
|
#[cfg(not(target_family = "wasm"))]
|
||||||
acp_selections: HashMap::new(),
|
acp_selections: HashMap::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -727,6 +742,8 @@ impl LLMPreferences {
|
|||||||
me.inject_openai_models(ctx);
|
me.inject_openai_models(ctx);
|
||||||
me.ensure_default_model_present();
|
me.ensure_default_model_present();
|
||||||
me.fetch_openai_models_from_endpoint(ctx);
|
me.fetch_openai_models_from_endpoint(ctx);
|
||||||
|
me.refresh_chatgpt_subscription_models(ctx);
|
||||||
|
me.schedule_chatgpt_subscription_model_refresh(ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
me
|
me
|
||||||
@@ -742,15 +759,10 @@ impl LLMPreferences {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
for default_model in &default_chatgpt_models {
|
if provider.models.is_empty() {
|
||||||
if !provider
|
provider.models = default_chatgpt_models.clone();
|
||||||
.models
|
providers_changed = true;
|
||||||
.iter()
|
continue;
|
||||||
.any(|model| model.model_id == default_model.model_id)
|
|
||||||
{
|
|
||||||
provider.models.push(default_model.clone());
|
|
||||||
providers_changed = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for model in &mut provider.models {
|
for model in &mut provider.models {
|
||||||
@@ -1216,43 +1228,41 @@ impl LLMPreferences {
|
|||||||
}
|
}
|
||||||
self.acp_selections.clear();
|
self.acp_selections.clear();
|
||||||
let settings = AISettings::as_ref(ctx);
|
let settings = AISettings::as_ref(ctx);
|
||||||
if !*settings.acp_enabled.value() {
|
let providers = settings.enabled_acp_providers();
|
||||||
|
if providers.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let configured_agent_id = settings.acp_agent_id.value().trim();
|
let bedrock_enabled = *settings.bedrock_enabled.value();
|
||||||
let configured_agent_id = if configured_agent_id.is_empty() {
|
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"
|
"codex"
|
||||||
} else {
|
} else {
|
||||||
configured_agent_id
|
agent_id
|
||||||
};
|
};
|
||||||
let bedrock_enabled = *settings.bedrock_enabled.value();
|
let agent_name = acp_agent_display_name(agent_id);
|
||||||
let configured_agent = settings
|
if provider.config_options.is_empty() {
|
||||||
.acp_agents
|
self.push_acp_model(provider, &agent_name, &agent_name, BTreeMap::new(), None);
|
||||||
.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,
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
};
|
}
|
||||||
let model_option = agent
|
|
||||||
|
let model_option = provider
|
||||||
.config_options
|
.config_options
|
||||||
.iter()
|
.iter()
|
||||||
.find(|option| option.category.as_deref() == Some("model"));
|
.find(|option| option.category.as_deref() == Some("model"));
|
||||||
let Some(model_option) = model_option else {
|
let Some(model_option) = model_option else {
|
||||||
let selection =
|
let selection =
|
||||||
crate::ai::acp::AcpRuntimeModel::current_config_values(&agent.config_options);
|
crate::ai::acp::AcpRuntimeModel::current_config_values(&provider.config_options);
|
||||||
self.push_acp_model(&agent.id, &agent.name, &agent.name, selection, None);
|
self.push_acp_model(provider, &agent_name, &agent_name, selection, None);
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let reasoning_option = agent
|
let reasoning_option = provider
|
||||||
.config_options
|
.config_options
|
||||||
.iter()
|
.iter()
|
||||||
.find(|option| option.category.as_deref() == Some("thought_level"));
|
.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))
|
.filter(|value| acp_model_is_enabled(&value.value, bedrock_enabled))
|
||||||
{
|
{
|
||||||
let mut selection =
|
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());
|
selection.insert(model_option.id.clone(), value.value.clone());
|
||||||
if let Some(reasoning_option) =
|
if let Some(reasoning_option) =
|
||||||
reasoning_option.filter(|option| !option.options.is_empty())
|
reasoning_option.filter(|option| !option.options.is_empty())
|
||||||
@@ -1271,7 +1281,7 @@ impl LLMPreferences {
|
|||||||
let mut selection = selection.clone();
|
let mut selection = selection.clone();
|
||||||
selection.insert(reasoning_option.id.clone(), reasoning.value.clone());
|
selection.insert(reasoning_option.id.clone(), reasoning.value.clone());
|
||||||
self.push_acp_model(
|
self.push_acp_model(
|
||||||
&agent.id,
|
provider,
|
||||||
&value.name,
|
&value.name,
|
||||||
&value.name,
|
&value.name,
|
||||||
selection,
|
selection,
|
||||||
@@ -1279,7 +1289,7 @@ impl LLMPreferences {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} 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"))]
|
#[cfg(not(target_family = "wasm"))]
|
||||||
fn push_acp_model(
|
fn push_acp_model(
|
||||||
&mut self,
|
&mut self,
|
||||||
agent_id: &str,
|
provider: &AcpProviderConfig,
|
||||||
display_name: &str,
|
display_name: &str,
|
||||||
base_model_name: &str,
|
base_model_name: &str,
|
||||||
selection: BTreeMap<String, serde_json::Value>,
|
selection: BTreeMap<String, serde_json::Value>,
|
||||||
@@ -1297,12 +1307,30 @@ impl LLMPreferences {
|
|||||||
|| display_name.to_owned(),
|
|| display_name.to_owned(),
|
||||||
|reasoning| format!("{display_name} ({})", reasoning.name),
|
|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());
|
let llm_id = LLMId::from(id.as_str());
|
||||||
self.acp_selections.insert(
|
self.acp_selections.insert(
|
||||||
llm_id.clone(),
|
llm_id.clone(),
|
||||||
AcpModelSelection {
|
AcpModelSelection {
|
||||||
|
provider_id: provider.id.clone(),
|
||||||
agent_id: agent_id.to_owned(),
|
agent_id: agent_id.to_owned(),
|
||||||
|
launch_fingerprint: acp_launch_fingerprint(
|
||||||
|
agent_id,
|
||||||
|
&provider.command,
|
||||||
|
&provider.args,
|
||||||
|
),
|
||||||
config_values: selection,
|
config_values: selection,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -1315,7 +1343,7 @@ impl LLMPreferences {
|
|||||||
request_multiplier: 1,
|
request_multiplier: 1,
|
||||||
credit_multiplier: None,
|
credit_multiplier: None,
|
||||||
},
|
},
|
||||||
description: None,
|
description: Some("ACP".to_string()),
|
||||||
disable_reason: None,
|
disable_reason: None,
|
||||||
vision_supported: false,
|
vision_supported: false,
|
||||||
spec: None,
|
spec: None,
|
||||||
@@ -1362,12 +1390,9 @@ impl LLMPreferences {
|
|||||||
let active_model = self.get_active_base_model(ctx, terminal_view_id);
|
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) {
|
if let Some(selection) = self.acp_runtime_selection_for_model(&active_model.id) {
|
||||||
return AgentBackend::Acp(AcpConversationData {
|
return AgentBackend::Acp(AcpConversationData {
|
||||||
|
provider_id: selection.provider_id.clone(),
|
||||||
agent_id: selection.agent_id.clone(),
|
agent_id: selection.agent_id.clone(),
|
||||||
launch_fingerprint: acp_launch_fingerprint(
|
launch_fingerprint: selection.launch_fingerprint.clone(),
|
||||||
&selection.agent_id,
|
|
||||||
settings.acp_agent_command.value(),
|
|
||||||
settings.acp_agent_args.value(),
|
|
||||||
),
|
|
||||||
session_id: None,
|
session_id: None,
|
||||||
config_values: selection.config_values.clone(),
|
config_values: selection.config_values.clone(),
|
||||||
});
|
});
|
||||||
@@ -1381,39 +1406,32 @@ impl LLMPreferences {
|
|||||||
// model option (and for agents that do not expose model selection at
|
// model option (and for agents that do not expose model selection at
|
||||||
// all). A discovered model catalog with no enabled entries must not
|
// all). A discovered model catalog with no enabled entries must not
|
||||||
// fall back to its disabled current model, though.
|
// fall back to its disabled current model, though.
|
||||||
let configured_agent_id = settings.acp_agent_id.value().trim();
|
let providers = settings.enabled_acp_providers();
|
||||||
let agent_id = if configured_agent_id.is_empty() {
|
let [provider] = providers.as_slice() else {
|
||||||
"codex"
|
return AgentBackend::Provider;
|
||||||
} else {
|
|
||||||
configured_agent_id
|
|
||||||
};
|
};
|
||||||
let configured_agent = settings
|
if provider
|
||||||
.acp_agents
|
.config_options
|
||||||
.value()
|
|
||||||
.iter()
|
.iter()
|
||||||
.find(|agent| agent.id.eq_ignore_ascii_case(agent_id));
|
.any(|option| option.category.as_deref() == Some("model"))
|
||||||
if configured_agent.is_some_and(|agent| {
|
{
|
||||||
agent
|
|
||||||
.config_options
|
|
||||||
.iter()
|
|
||||||
.any(|option| option.category.as_deref() == Some("model"))
|
|
||||||
}) {
|
|
||||||
return AgentBackend::Provider;
|
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 {
|
AgentBackend::Acp(AcpConversationData {
|
||||||
|
provider_id: provider.id.clone(),
|
||||||
agent_id: agent_id.to_owned(),
|
agent_id: agent_id.to_owned(),
|
||||||
launch_fingerprint: acp_launch_fingerprint(
|
launch_fingerprint: acp_launch_fingerprint(agent_id, &provider.command, &provider.args),
|
||||||
agent_id,
|
|
||||||
settings.acp_agent_command.value(),
|
|
||||||
settings.acp_agent_args.value(),
|
|
||||||
),
|
|
||||||
session_id: None,
|
session_id: None,
|
||||||
config_values: configured_agent
|
config_values: crate::ai::acp::AcpRuntimeModel::current_config_values(
|
||||||
.map(|agent| {
|
&provider.config_options,
|
||||||
crate::ai::acp::AcpRuntimeModel::current_config_values(&agent.config_options)
|
),
|
||||||
})
|
|
||||||
.unwrap_or_default(),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1537,17 +1555,32 @@ impl LLMPreferences {
|
|||||||
else {
|
else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
if provider.base_url.trim().is_empty() {
|
if provider.kind != OpenAIProviderKind::ChatGPTSubscription
|
||||||
|
&& provider.base_url.trim().is_empty()
|
||||||
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let provider_kind = provider.kind;
|
let provider_kind = provider.kind;
|
||||||
let requested_base_url = provider.base_url;
|
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 api_key = provider.api_key.filter(|key| !key.is_empty());
|
||||||
let request_base_url = requested_base_url.clone();
|
let request_base_url = requested_base_url.clone();
|
||||||
|
|
||||||
let _ = ctx.spawn(
|
let _ = ctx.spawn(
|
||||||
async move {
|
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 base = request_base_url.trim_end_matches('/');
|
||||||
let client = reqwest::Client::builder()
|
let client = reqwest::Client::builder()
|
||||||
.timeout(std::time::Duration::from_secs(10))
|
.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
|
// Do not apply a response to an entry that was edited or
|
||||||
// reordered while its discovery request was in flight.
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
provider.models =
|
provider.models = if provider.kind == OpenAIProviderKind::ChatGPTSubscription {
|
||||||
merge_discovered_provider_models(&provider.models, discovered_models);
|
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) {
|
if let Err(err) = settings.openai_providers.set_value(providers, ctx) {
|
||||||
report_error!(err.context("Failed to persist discovered provider models"));
|
report_error!(err.context("Failed to persist discovered provider models"));
|
||||||
}
|
}
|
||||||
@@ -1599,6 +1640,10 @@ impl LLMPreferences {
|
|||||||
pub(crate) async fn discover_openai_provider_models(
|
pub(crate) async fn discover_openai_provider_models(
|
||||||
provider: OpenAIProviderConfig,
|
provider: OpenAIProviderConfig,
|
||||||
) -> Result<Vec<OpenAIModelConfig>, String> {
|
) -> Result<Vec<OpenAIModelConfig>, String> {
|
||||||
|
if provider.kind == OpenAIProviderKind::ChatGPTSubscription {
|
||||||
|
return Self::discover_chatgpt_subscription_models().await;
|
||||||
|
}
|
||||||
|
|
||||||
let native_models = match provider.kind {
|
let native_models = match provider.kind {
|
||||||
OpenAIProviderKind::Anthropic => {
|
OpenAIProviderKind::Anthropic => {
|
||||||
let api_key = provider
|
let api_key = provider
|
||||||
@@ -1637,9 +1682,10 @@ impl LLMPreferences {
|
|||||||
)?;
|
)?;
|
||||||
Some(vertex_ai_model_catalog())
|
Some(vertex_ai_model_catalog())
|
||||||
}
|
}
|
||||||
OpenAIProviderKind::OpenAI
|
OpenAIProviderKind::OpenAI | OpenAIProviderKind::LiteLLM => None,
|
||||||
| OpenAIProviderKind::LiteLLM
|
OpenAIProviderKind::ChatGPTSubscription => {
|
||||||
| OpenAIProviderKind::ChatGPTSubscription => None,
|
unreachable!("ChatGPT subscription discovery is handled before native discovery")
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(models) = native_models {
|
if let Some(models) = native_models {
|
||||||
@@ -1684,6 +1730,93 @@ impl LLMPreferences {
|
|||||||
Ok(models)
|
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"))]
|
#[cfg(not(target_family = "wasm"))]
|
||||||
fn rig_models_to_openai_models(models: Vec<RigModelInfo>) -> Vec<OpenAIModelConfig> {
|
fn rig_models_to_openai_models(models: Vec<RigModelInfo>) -> Vec<OpenAIModelConfig> {
|
||||||
models
|
models
|
||||||
@@ -2539,6 +2672,44 @@ pub(crate) fn merge_discovered_provider_models(
|
|||||||
merged
|
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"))]
|
#[cfg(not(target_family = "wasm"))]
|
||||||
fn openai_model_variant_id(model_id: &str, reasoning_effort: &str) -> String {
|
fn openai_model_variant_id(model_id: &str, reasoning_effort: &str) -> String {
|
||||||
format!("{model_id}::reasoning::{reasoning_effort}")
|
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
|
/// Fetches model metadata from LiteLLM's `/model/info` endpoint which returns rich
|
||||||
/// metadata including accurate context window sizes, output token limits, and
|
/// metadata including accurate context window sizes, output token limits, and
|
||||||
/// capability flags (vision, function calling).
|
/// capability flags (vision, function calling).
|
||||||
|
|||||||
+186
-12
@@ -11,7 +11,8 @@ use crate::server::cloud_objects::update_manager::UpdateManager;
|
|||||||
use crate::server::server_api::ServerApiProvider;
|
use crate::server::server_api::ServerApiProvider;
|
||||||
use crate::server::sync_queue::SyncQueue;
|
use crate::server::sync_queue::SyncQueue;
|
||||||
use crate::settings::{
|
use crate::settings::{
|
||||||
AcpAgentSettings, AcpConfigOptionSettings, AcpConfigValueSettings, OpenAIModelConfig,
|
AcpAgentSettings, AcpConfigOptionSettings, AcpConfigValueSettings, AcpProviderConfig,
|
||||||
|
OpenAIModelConfig,
|
||||||
};
|
};
|
||||||
use crate::test_util::settings::initialize_settings_for_tests;
|
use crate::test_util::settings::initialize_settings_for_tests;
|
||||||
use crate::workspaces::team_tester::TeamTesterStatus;
|
use crate::workspaces::team_tester::TeamTesterStatus;
|
||||||
@@ -210,6 +211,7 @@ fn empty_preferences() -> LLMPreferences {
|
|||||||
custom_model_routers: Vec::new(),
|
custom_model_routers: Vec::new(),
|
||||||
openai_provider_routing: HashMap::new(),
|
openai_provider_routing: HashMap::new(),
|
||||||
fetched_openai_models: Vec::new(),
|
fetched_openai_models: Vec::new(),
|
||||||
|
chatgpt_subscription_models_refresh_in_flight: false,
|
||||||
acp_selections: HashMap::new(),
|
acp_selections: HashMap::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -299,6 +301,7 @@ fn acp_models_are_injected_only_while_acp_is_enabled() {
|
|||||||
custom_model_routers: Vec::new(),
|
custom_model_routers: Vec::new(),
|
||||||
openai_provider_routing: HashMap::new(),
|
openai_provider_routing: HashMap::new(),
|
||||||
fetched_openai_models: Vec::new(),
|
fetched_openai_models: Vec::new(),
|
||||||
|
chatgpt_subscription_models_refresh_in_flight: false,
|
||||||
acp_selections: HashMap::new(),
|
acp_selections: HashMap::new(),
|
||||||
};
|
};
|
||||||
app.read(|ctx| preferences.inject_acp_models(ctx));
|
app.read(|ctx| preferences.inject_acp_models(ctx));
|
||||||
@@ -452,7 +455,7 @@ fn acp_models_expand_reasoning_levels_for_only_the_configured_agent() {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|model| model.display_name.as_str())
|
.map(|model| model.display_name.as_str())
|
||||||
.collect::<HashSet<_>>(),
|
.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| {
|
assert!(models.iter().all(|model| {
|
||||||
model.provider == LLMProvider::Acp && !model.display_name.contains("Read-only")
|
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]
|
#[test]
|
||||||
fn acp_bedrock_models_are_hidden_while_bedrock_is_disabled() {
|
fn acp_bedrock_models_are_hidden_while_bedrock_is_disabled() {
|
||||||
App::test((), |mut app| async move {
|
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.len(), 1);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
preferences.models_by_feature.agent_mode.choices[0].display_name,
|
preferences.models_by_feature.agent_mode.choices[0].display_name,
|
||||||
"GPT Test"
|
"GPT Test · OpenCode"
|
||||||
);
|
);
|
||||||
|
|
||||||
AISettings::handle(&app).update(&mut app, |settings, ctx| {
|
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");
|
let gpt_56_sol = configured_model("gpt-5.6-sol");
|
||||||
assert_eq!(gpt_56_sol.context_size, 272_000);
|
assert_eq!(gpt_56_sol.context_size, 272_000);
|
||||||
assert_eq!(gpt_56_sol.max_input_tokens, Some(258_400));
|
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");
|
let uncached_model = configured_model("gpt-5.4-pro");
|
||||||
assert_eq!(uncached_model.context_size, 200_000);
|
assert_eq!(uncached_model.context_size, 200_000);
|
||||||
assert_eq!(uncached_model.max_input_tokens, None);
|
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.reasoning_effort.as_deref(), Some("ultra"));
|
||||||
assert_eq!(ultra_routing.max_input_tokens, Some(258_400));
|
assert_eq!(ultra_routing.max_input_tokens, Some(258_400));
|
||||||
|
|
||||||
let spark_id = "gpt-5.3-codex-spark";
|
let uncached_id = "gpt-5.4-pro";
|
||||||
assert_fixed_context(spark_id, 121_600);
|
assert_fixed_context(uncached_id, 200_000);
|
||||||
let spark_routing = preferences
|
let uncached_routing = preferences
|
||||||
.openai_client_config_for_model(spark_id)
|
.openai_client_config_for_model(uncached_id)
|
||||||
.expect("GPT-5.3 Codex Spark should have a routing entry");
|
.expect("GPT-5.4 Pro should have a routing entry");
|
||||||
assert_eq!(spark_routing.max_input_tokens, Some(121_600));
|
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[0].supports_system_messages, Some(false));
|
||||||
assert_eq!(merged[1].model_id, "manual-model");
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ pub mod provider;
|
|||||||
#[cfg(all(not(target_family = "wasm"), feature = "local_fs"))]
|
#[cfg(all(not(target_family = "wasm"), feature = "local_fs"))]
|
||||||
pub(crate) mod remote_agent_context;
|
pub(crate) mod remote_agent_context;
|
||||||
pub(crate) mod remote_context_files;
|
pub(crate) mod remote_context_files;
|
||||||
|
#[cfg(not(target_family = "wasm"))]
|
||||||
|
pub(crate) mod remote_logging;
|
||||||
pub mod request_usage_model;
|
pub mod request_usage_model;
|
||||||
pub(crate) mod restored_conversations;
|
pub(crate) mod restored_conversations;
|
||||||
pub(crate) mod runtime;
|
pub(crate) mod runtime;
|
||||||
|
|||||||
@@ -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
@@ -10,7 +10,7 @@ mod macos_app_icon {
|
|||||||
pub use objc2::rc::autoreleasepool;
|
pub use objc2::rc::autoreleasepool;
|
||||||
pub use objc2::{AnyThread, MainThreadMarker};
|
pub use objc2::{AnyThread, MainThreadMarker};
|
||||||
pub use objc2_app_kit::{NSApplication, NSImage, NSWorkspace, NSWorkspaceIconCreationOptions};
|
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};
|
pub use crate::settings::app_icon::{AppIcon, AppIconSettings, AppIconSettingsChangedEvent};
|
||||||
}
|
}
|
||||||
@@ -211,6 +211,7 @@ impl AppearanceManager {
|
|||||||
let ns_app = NSApplication::sharedApplication(mtm);
|
let ns_app = NSApplication::sharedApplication(mtm);
|
||||||
let bundle = NSBundle::mainBundle();
|
let bundle = NSBundle::mainBundle();
|
||||||
let bundle_path = bundle.bundlePath();
|
let bundle_path = bundle.bundlePath();
|
||||||
|
let is_bundled_app = bundle.bundleIdentifier().is_some();
|
||||||
let workspace = NSWorkspace::sharedWorkspace();
|
let workspace = NSWorkspace::sharedWorkspace();
|
||||||
|
|
||||||
// If the user has selected the default icon, reset to the icon that is statically
|
// 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
|
// 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.
|
// preferred icon style, but that icon style _will_ apply on next app restart.
|
||||||
if icon == AppIcon::Galaxy
|
if icon == AppIcon::Galaxy
|
||||||
|
&& is_bundled_app
|
||||||
&& ChannelState::channel() != Channel::Local
|
&& ChannelState::channel() != Channel::Local
|
||||||
&& self.app_icon_at_startup == AppIcon::Galaxy
|
&& self.app_icon_at_startup == AppIcon::Galaxy
|
||||||
{
|
{
|
||||||
@@ -245,32 +247,20 @@ impl AppearanceManager {
|
|||||||
let icon_name = AppIconSettings::get_base_icon_file_name(icon);
|
let icon_name = AppIconSettings::get_base_icon_file_name(icon);
|
||||||
|
|
||||||
log::debug!("Setting app icon in memory to: {icon_name}");
|
log::debug!("Setting app icon in memory to: {icon_name}");
|
||||||
// Locate the plugin bundle.
|
let image = if let Some(image) = load_app_icon_from_plugin_bundle(&bundle, icon_name) {
|
||||||
let Some(plugins_path) = bundle.builtInPlugInsPath() else {
|
image
|
||||||
log::warn!("Failed to get dock tile plugin bundle");
|
} else {
|
||||||
return;
|
let asset_path = format!("bundled/png/{icon_name}.png");
|
||||||
};
|
let Ok(image_bytes) = ASSETS.get(&asset_path) else {
|
||||||
let plugin_name = ns_string!("WarpDockTilePlugin.docktileplugin");
|
log::warn!("Failed to get bundled app icon asset: {asset_path}");
|
||||||
let plugin_path = plugins_path.stringByAppendingPathComponent(plugin_name);
|
return;
|
||||||
let Some(plugin_bundle) = NSBundle::bundleWithPath(&plugin_path) else {
|
};
|
||||||
log::warn!("Failed to get dock tile plugin bundle");
|
let data = NSData::with_bytes(image_bytes.as_ref());
|
||||||
return;
|
let Some(image) = NSImage::initWithData(NSImage::alloc(), &data) else {
|
||||||
};
|
log::warn!("Failed to create image from bundled app icon asset: {asset_path}");
|
||||||
|
return;
|
||||||
// Read the images from the plugin bundle.
|
};
|
||||||
let image_name = NSString::from_str(icon_name);
|
image
|
||||||
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;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Override the bundled icon with this new 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 {
|
impl Entity for AppearanceManager {
|
||||||
type Event = ();
|
type Event = ();
|
||||||
}
|
}
|
||||||
|
|||||||
+211
-3
@@ -1032,10 +1032,94 @@ impl settings_value::SettingsValue for OpenAIProviderConfig {}
|
|||||||
const INITIAL_LITELLM_BASE_URL: &str = "https://ai.ryserve.net/v1";
|
const INITIAL_LITELLM_BASE_URL: &str = "https://ai.ryserve.net/v1";
|
||||||
const INITIAL_RIG_MODEL_ID: &str = "codex-gpt-5.6-sol-xhigh";
|
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> {
|
fn default_chatgpt_models() -> Vec<OpenAIModelConfig> {
|
||||||
// The ChatGPT OAuth backend does not expose a model-listing capability through Rig,
|
// Fallback catalog used before the first successful Codex model discovery.
|
||||||
// so keep this catalog small and explicit. Context limits come from Codex model
|
// Once discovery succeeds, the saved ChatGPT subscription catalog is treated
|
||||||
// metadata; models absent from that catalog retain the generic fallback.
|
// as backend-owned so removed models do not get reintroduced on startup.
|
||||||
[
|
[
|
||||||
(
|
(
|
||||||
"gpt-5.6-sol",
|
"gpt-5.6-sol",
|
||||||
@@ -1578,6 +1662,17 @@ define_settings_group!(AISettings, settings: [
|
|||||||
description: "Arguments passed to the local Agent Client Protocol agent executable.",
|
description: "Arguments passed to the local Agent Client Protocol agent executable.",
|
||||||
feature_flag: FeatureFlag::AgentClientProtocol,
|
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.
|
// Cached ACP registry and runtime discovery data. Values are refreshed when the agent is queried.
|
||||||
acp_agents: AcpAgents {
|
acp_agents: AcpAgents {
|
||||||
type: Vec<AcpAgentSettings>,
|
type: Vec<AcpAgentSettings>,
|
||||||
@@ -1772,6 +1867,57 @@ define_settings_group!(AISettings, settings: [
|
|||||||
toml_path: "ai.providers",
|
toml_path: "ai.providers",
|
||||||
description: "Multiple OpenAI-compatible provider endpoints (e.g. LiteLLM, Ollama, local models).",
|
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.
|
// Whether or not the user wants agent mode requests to use their saved rules.
|
||||||
memory_enabled: MemoryEnabled {
|
memory_enabled: MemoryEnabled {
|
||||||
type: bool,
|
type: bool,
|
||||||
@@ -2315,6 +2461,68 @@ impl AISettings {
|
|||||||
&& !self.is_ai_disabled_due_to_remote_session_org_policy(app)
|
&& !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.
|
/// Returns whether Galaxy has a local model provider or agent runtime enabled.
|
||||||
pub fn has_enabled_ai_runtime(&self) -> bool {
|
pub fn has_enabled_ai_runtime(&self) -> bool {
|
||||||
*self.bedrock_enabled.value()
|
*self.bedrock_enabled.value()
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ use crate::server::telemetry::{
|
|||||||
use crate::settings::ai::OpenAIProviderKind;
|
use crate::settings::ai::OpenAIProviderKind;
|
||||||
use crate::settings::{
|
use crate::settings::{
|
||||||
AIAutoDetectionEnabled, AICommandDenylist, AISettings, AISettingsChangedEvent, AcpEnabled,
|
AIAutoDetectionEnabled, AICommandDenylist, AISettings, AISettingsChangedEvent, AcpEnabled,
|
||||||
AgentModeCodingPermissionsType, AgentModeCommandExecutionDenylist,
|
AcpProviderConfig, AgentModeCodingPermissionsType, AgentModeCommandExecutionDenylist,
|
||||||
AgentModeCommandExecutionPredicate, AgentModeQuerySuggestionsEnabled, BedrockAutoLogin,
|
AgentModeCommandExecutionPredicate, AgentModeQuerySuggestionsEnabled, BedrockAutoLogin,
|
||||||
BedrockEnabled, BedrockModelConfig, CodeSettings, CodebaseContextEnabled, CrosscheckEnabled,
|
BedrockEnabled, BedrockModelConfig, CodeSettings, CodebaseContextEnabled, CrosscheckEnabled,
|
||||||
FileBasedMcpEnabled, GitOperationsAutogenEnabled, IncludeAgentCommandsInHistory, InputSettings,
|
FileBasedMcpEnabled, GitOperationsAutogenEnabled, IncludeAgentCommandsInHistory, InputSettings,
|
||||||
@@ -2054,7 +2054,24 @@ impl AISettingsPageView {
|
|||||||
|
|
||||||
fn save_acp_provider(&mut self, draft: AcpProviderDraft, ctx: &mut ViewContext<Self>) {
|
fn save_acp_provider(&mut self, draft: AcpProviderDraft, ctx: &mut ViewContext<Self>) {
|
||||||
AISettings::handle(ctx).update(ctx, |settings, ctx| {
|
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_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_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_command.set_value(draft.command, ctx));
|
||||||
report_if_error!(settings.acp_agent_args.set_value(draft.args, ctx));
|
report_if_error!(settings.acp_agent_args.set_value(draft.args, ctx));
|
||||||
@@ -2989,6 +3006,7 @@ pub enum AISettingsPageAction {
|
|||||||
RemoveOpenAIProvider(usize),
|
RemoveOpenAIProvider(usize),
|
||||||
EditBedrockProvider,
|
EditBedrockProvider,
|
||||||
RemoveBedrockProvider,
|
RemoveBedrockProvider,
|
||||||
|
RemoveAcpProvider(String),
|
||||||
ToggleFileBasedMcp,
|
ToggleFileBasedMcp,
|
||||||
ToggleIncludeAgentCommandsInHistory,
|
ToggleIncludeAgentCommandsInHistory,
|
||||||
ToggleAgentAttribution,
|
ToggleAgentAttribution,
|
||||||
@@ -3774,6 +3792,51 @@ impl TypedActionView for AISettingsPageView {
|
|||||||
});
|
});
|
||||||
self.rebuild_active_subpage(ctx);
|
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 => {
|
AISettingsPageAction::ToggleFileBasedMcp => {
|
||||||
AISettings::handle(ctx).update(ctx, |settings, ctx| {
|
AISettings::handle(ctx).update(ctx, |settings, ctx| {
|
||||||
report_if_error!(settings.file_based_mcp_enabled.toggle_and_save_value(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())
|
.map(|provider| provider.models.len())
|
||||||
.sum::<usize>();
|
.sum::<usize>();
|
||||||
let bedrock_model_count = settings.bedrock_models.value().len();
|
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()
|
Flex::column()
|
||||||
.with_spacing(8.)
|
.with_spacing(8.)
|
||||||
@@ -7161,6 +7224,10 @@ struct OpenAIProviderCardState {
|
|||||||
remove_button: ViewHandle<ActionButton>,
|
remove_button: ViewHandle<ActionButton>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct AcpProviderCardState {
|
||||||
|
remove_button: ViewHandle<ActionButton>,
|
||||||
|
}
|
||||||
|
|
||||||
struct ProviderSettingsWidget {
|
struct ProviderSettingsWidget {
|
||||||
provider_type: ProviderSetupProviderType,
|
provider_type: ProviderSetupProviderType,
|
||||||
enabled_toggle: SwitchStateHandle,
|
enabled_toggle: SwitchStateHandle,
|
||||||
@@ -7173,6 +7240,7 @@ struct ProviderSettingsWidget {
|
|||||||
bedrock_edit_button: ViewHandle<ActionButton>,
|
bedrock_edit_button: ViewHandle<ActionButton>,
|
||||||
bedrock_remove_button: ViewHandle<ActionButton>,
|
bedrock_remove_button: ViewHandle<ActionButton>,
|
||||||
provider_cards: Vec<OpenAIProviderCardState>,
|
provider_cards: Vec<OpenAIProviderCardState>,
|
||||||
|
acp_provider_cards: Vec<AcpProviderCardState>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProviderSettingsWidget {
|
impl ProviderSettingsWidget {
|
||||||
@@ -7200,6 +7268,25 @@ impl ProviderSettingsWidget {
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
.collect();
|
.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(|_| {
|
let add_openai_provider_button = ctx.add_typed_action_view(|_| {
|
||||||
ActionButton::new("Add provider", SecondaryTheme)
|
ActionButton::new("Add provider", SecondaryTheme)
|
||||||
.with_icon(Icon::Plus)
|
.with_icon(Icon::Plus)
|
||||||
@@ -7253,7 +7340,6 @@ impl ProviderSettingsWidget {
|
|||||||
ctx.dispatch_typed_action(AISettingsPageAction::RemoveBedrockProvider);
|
ctx.dispatch_typed_action(AISettingsPageAction::RemoveBedrockProvider);
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
provider_type,
|
provider_type,
|
||||||
enabled_toggle: SwitchStateHandle::default(),
|
enabled_toggle: SwitchStateHandle::default(),
|
||||||
@@ -7266,6 +7352,7 @@ impl ProviderSettingsWidget {
|
|||||||
bedrock_edit_button,
|
bedrock_edit_button,
|
||||||
bedrock_remove_button,
|
bedrock_remove_button,
|
||||||
provider_cards,
|
provider_cards,
|
||||||
|
acp_provider_cards,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7439,15 +7526,11 @@ impl ProviderSettingsWidget {
|
|||||||
Self::render_model_catalog(rows, "No Bedrock models discovered yet.", appearance)
|
Self::render_model_catalog(rows, "No Bedrock models discovered yet.", appearance)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_acp_model_catalog(appearance: &Appearance, app: &AppContext) -> Box<dyn Element> {
|
fn render_acp_model_catalog(
|
||||||
let settings = AISettings::as_ref(app);
|
provider: &AcpProviderConfig,
|
||||||
let selected_agent_id = settings.acp_agent_id.value();
|
appearance: &Appearance,
|
||||||
let Some(agent) = settings
|
) -> Box<dyn Element> {
|
||||||
.acp_agents
|
if provider.config_options.is_empty() {
|
||||||
.value()
|
|
||||||
.iter()
|
|
||||||
.find(|agent| agent.id.eq_ignore_ascii_case(selected_agent_id))
|
|
||||||
else {
|
|
||||||
return Text::new(
|
return Text::new(
|
||||||
"No ACP model or mode catalog has been discovered yet.",
|
"No ACP model or mode catalog has been discovered yet.",
|
||||||
appearance.ui_font_family(),
|
appearance.ui_font_family(),
|
||||||
@@ -7456,9 +7539,9 @@ impl ProviderSettingsWidget {
|
|||||||
.with_color(appearance.theme().nonactive_ui_text_color().into())
|
.with_color(appearance.theme().nonactive_ui_text_color().into())
|
||||||
.soft_wrap(true)
|
.soft_wrap(true)
|
||||||
.finish();
|
.finish();
|
||||||
};
|
}
|
||||||
|
|
||||||
let rows = agent
|
let rows = provider
|
||||||
.config_options
|
.config_options
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|option| {
|
.filter(|option| {
|
||||||
@@ -7672,13 +7755,15 @@ impl ProviderSettingsWidget {
|
|||||||
|
|
||||||
fn render_acp_provider_card(
|
fn render_acp_provider_card(
|
||||||
&self,
|
&self,
|
||||||
title: &str,
|
provider_index: usize,
|
||||||
|
provider: &AcpProviderConfig,
|
||||||
description: &'static str,
|
description: &'static str,
|
||||||
appearance: &Appearance,
|
appearance: &Appearance,
|
||||||
app: &AppContext,
|
|
||||||
) -> Box<dyn Element> {
|
) -> Box<dyn Element> {
|
||||||
let settings = AISettings::as_ref(app);
|
let Some(card_state) = self.acp_provider_cards.get(provider_index) else {
|
||||||
let status = format!("Read-only · Agent: {}", settings.acp_agent_id.value());
|
return Empty::new().finish();
|
||||||
|
};
|
||||||
|
let status = format!("Read-only · Agent: {}", provider.agent_id);
|
||||||
let header = Flex::row()
|
let header = Flex::row()
|
||||||
.with_main_axis_size(MainAxisSize::Max)
|
.with_main_axis_size(MainAxisSize::Max)
|
||||||
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
||||||
@@ -7688,7 +7773,7 @@ impl ProviderSettingsWidget {
|
|||||||
.with_spacing(4.)
|
.with_spacing(4.)
|
||||||
.with_child(
|
.with_child(
|
||||||
Text::new(
|
Text::new(
|
||||||
title.to_string(),
|
provider.display_name(),
|
||||||
appearance.ui_font_family(),
|
appearance.ui_font_family(),
|
||||||
appearance.header_font_size(),
|
appearance.header_font_size(),
|
||||||
)
|
)
|
||||||
@@ -7705,8 +7790,15 @@ impl ProviderSettingsWidget {
|
|||||||
.finish(),
|
.finish(),
|
||||||
)
|
)
|
||||||
.with_child(
|
.with_child(
|
||||||
Text::new(status, appearance.ui_font_family(), CONTENT_FONT_SIZE)
|
Flex::row()
|
||||||
.with_color(appearance.theme().nonactive_ui_text_color().into())
|
.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(),
|
||||||
)
|
)
|
||||||
.finish();
|
.finish();
|
||||||
@@ -7715,7 +7807,7 @@ impl ProviderSettingsWidget {
|
|||||||
Flex::column()
|
Flex::column()
|
||||||
.with_spacing(12.)
|
.with_spacing(12.)
|
||||||
.with_child(header)
|
.with_child(header)
|
||||||
.with_child(Self::render_acp_model_catalog(appearance, app))
|
.with_child(Self::render_acp_model_catalog(provider, appearance))
|
||||||
.finish(),
|
.finish(),
|
||||||
)
|
)
|
||||||
.with_padding(Padding::uniform(16.))
|
.with_padding(Padding::uniform(16.))
|
||||||
@@ -7977,22 +8069,28 @@ impl SettingsWidget for ProviderSettingsWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let acp_supported = cfg!(unix) && FeatureFlag::AgentClientProtocol.is_enabled();
|
let acp_supported = cfg!(unix) && FeatureFlag::AgentClientProtocol.is_enabled();
|
||||||
let acp_cards = if acp_supported && *settings.acp_enabled.value() {
|
let acp_cards = if acp_supported {
|
||||||
vec![self.render_acp_provider_card(
|
settings
|
||||||
settings.acp_connection_name.value().as_str(),
|
.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.",
|
"Use a local session-oriented agent that owns its model, login, session, and tool loop.",
|
||||||
appearance,
|
appearance,
|
||||||
app,
|
)
|
||||||
)]
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
} else {
|
} else {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
};
|
};
|
||||||
let acp_add_button =
|
let acp_add_button = if acp_supported && !is_setup_visible {
|
||||||
if acp_supported && !is_setup_visible && !*settings.acp_enabled.value() {
|
Some(&self.acp_add_button)
|
||||||
Some(&self.acp_add_button)
|
} else {
|
||||||
} else {
|
None
|
||||||
None
|
};
|
||||||
};
|
|
||||||
let empty_message = if acp_supported {
|
let empty_message = if acp_supported {
|
||||||
"No ACP agent connection configured."
|
"No ACP agent connection configured."
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -996,6 +996,10 @@ impl ProviderSetupView {
|
|||||||
| ProviderSetupProviderType::VertexAI => {}
|
| ProviderSetupProviderType::VertexAI => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.discovery_state = DiscoveryState::Loading;
|
||||||
|
self.update_next_button(ctx);
|
||||||
|
ctx.notify();
|
||||||
|
|
||||||
let provider = self.draft_provider();
|
let provider = self.draft_provider();
|
||||||
let existing_models = self.draft_models.clone();
|
let existing_models = self.draft_models.clone();
|
||||||
ctx.spawn(
|
ctx.spawn(
|
||||||
@@ -1033,15 +1037,34 @@ impl ProviderSetupView {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.draft_models.is_empty() {
|
self.discovery_state = DiscoveryState::Loading;
|
||||||
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.update_next_button(ctx);
|
self.update_next_button(ctx);
|
||||||
ctx.focus(&self.name_editor);
|
|
||||||
ctx.notify();
|
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")]
|
#[cfg(target_family = "wasm")]
|
||||||
|
|||||||
@@ -1043,6 +1043,7 @@ impl AgentBackend {
|
|||||||
match self {
|
match self {
|
||||||
Self::Provider => Self::Provider,
|
Self::Provider => Self::Provider,
|
||||||
Self::Acp(acp) => Self::Acp(AcpConversationData {
|
Self::Acp(acp) => Self::Acp(AcpConversationData {
|
||||||
|
provider_id: acp.provider_id.clone(),
|
||||||
agent_id: acp.agent_id.clone(),
|
agent_id: acp.agent_id.clone(),
|
||||||
launch_fingerprint: acp.launch_fingerprint.clone(),
|
launch_fingerprint: acp.launch_fingerprint.clone(),
|
||||||
session_id: None,
|
session_id: None,
|
||||||
@@ -1059,6 +1060,8 @@ impl AgentBackend {
|
|||||||
/// different executable after those settings change.
|
/// different executable after those settings change.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
pub struct AcpConversationData {
|
pub struct AcpConversationData {
|
||||||
|
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||||
|
pub provider_id: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub agent_id: String,
|
pub agent_id: String,
|
||||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use super::{
|
|||||||
#[test]
|
#[test]
|
||||||
fn acp_backend_fork_keeps_agent_identity_but_clears_session() {
|
fn acp_backend_fork_keeps_agent_identity_but_clears_session() {
|
||||||
let source = AgentBackend::Acp(AcpConversationData {
|
let source = AgentBackend::Acp(AcpConversationData {
|
||||||
|
provider_id: "provider-1".to_owned(),
|
||||||
agent_id: "codex".to_owned(),
|
agent_id: "codex".to_owned(),
|
||||||
launch_fingerprint: "launch-123".to_owned(),
|
launch_fingerprint: "launch-123".to_owned(),
|
||||||
session_id: Some("shared-session".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!(
|
assert_eq!(
|
||||||
source.for_fork(),
|
source.for_fork(),
|
||||||
AgentBackend::Acp(AcpConversationData {
|
AgentBackend::Acp(AcpConversationData {
|
||||||
|
provider_id: "provider-1".to_owned(),
|
||||||
agent_id: "codex".to_owned(),
|
agent_id: "codex".to_owned(),
|
||||||
launch_fingerprint: "launch-123".to_owned(),
|
launch_fingerprint: "launch-123".to_owned(),
|
||||||
session_id: None,
|
session_id: None,
|
||||||
@@ -183,6 +185,7 @@ fn agent_conversation_data_defaults_legacy_rows_to_provider_backend() {
|
|||||||
fn agent_conversation_data_roundtrips_acp_backend() {
|
fn agent_conversation_data_roundtrips_acp_backend() {
|
||||||
let data = AgentConversationData {
|
let data = AgentConversationData {
|
||||||
agent_backend: AgentBackend::Acp(AcpConversationData {
|
agent_backend: AgentBackend::Acp(AcpConversationData {
|
||||||
|
provider_id: "provider-1".to_string(),
|
||||||
agent_id: "codex-acp".to_string(),
|
agent_id: "codex-acp".to_string(),
|
||||||
launch_fingerprint: "launch-123".to_string(),
|
launch_fingerprint: "launch-123".to_string(),
|
||||||
session_id: Some("session-123".to_string()),
|
session_id: Some("session-123".to_string()),
|
||||||
|
|||||||
+9
-3
@@ -317,7 +317,8 @@ elif [[ $RELEASE_CHANNEL = "oss" ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
OUT_DIR="target/$TARGET_PROFILE_DIR/bundle/osx"
|
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
|
# Handle specific architecture targeting
|
||||||
if [[ -n "$TARGET_ARCH" ]]; then
|
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.
|
# 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..."
|
echo "Creating PlugIns directory and copying pre-built DockTilePlugin..."
|
||||||
mkdir -p "$BUNDLE_DIR/$WARP_APP_NAME.app/Contents/PlugIns"
|
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/"
|
cp -R "$DOCK_TILE_PLUGIN_DIR" "$BUNDLE_DIR/$WARP_APP_NAME.app/Contents/PlugIns/"
|
||||||
|
|
||||||
echo "Updating plist with dock tile plugin entries"
|
echo "Updating plist with dock tile plugin entries"
|
||||||
plutil -insert NSDockTilePlugIn -string "WarpDockTilePlugin.docktileplugin" "$BUNDLE_DIR"/$WARP_APP_NAME.app/Contents/Info.plist
|
APP_BUNDLE_PLIST="$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_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"
|
BUNDLED_RESOURCES_DIR="$BUNDLE_DIR/$WARP_APP_NAME.app/Contents/Resources"
|
||||||
echo "Preparing bundled resources..."
|
echo "Preparing bundled resources..."
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ else
|
|||||||
WARP_APP_PATH="${TARGET_DIR}/debug/bundle/osx/Galaxy.app"
|
WARP_APP_PATH="${TARGET_DIR}/debug/bundle/osx/Galaxy.app"
|
||||||
WARP_SCHEME_NAME="galaxyoss"
|
WARP_SCHEME_NAME="galaxyoss"
|
||||||
fi
|
fi
|
||||||
|
TARGET_PROFILE_DIR="debug"
|
||||||
DONT_OPEN=false
|
DONT_OPEN=false
|
||||||
# Launches the binary with "open", meaning the Galaxy process is
|
# Launches the binary with "open", meaning the Galaxy process is
|
||||||
# launched by the MacOS application launcher instead of a shell session.
|
# launched by the MacOS application launcher instead of a shell session.
|
||||||
@@ -67,6 +68,7 @@ while (( "$#" )); do
|
|||||||
;;
|
;;
|
||||||
--release)
|
--release)
|
||||||
echo "Detected release build, pointing at release bundle under ${TARGET_DIR}/release/bundle"
|
echo "Detected release build, pointing at release bundle under ${TARGET_DIR}/release/bundle"
|
||||||
|
TARGET_PROFILE_DIR="release"
|
||||||
if [ "$WARP_CHANNEL" = "local" ]; then
|
if [ "$WARP_CHANNEL" = "local" ]; then
|
||||||
WARP_APP_PATH="${TARGET_DIR}/release/bundle/osx/Galaxy Local.app"
|
WARP_APP_PATH="${TARGET_DIR}/release/bundle/osx/Galaxy Local.app"
|
||||||
else
|
else
|
||||||
@@ -77,6 +79,7 @@ while (( "$#" )); do
|
|||||||
;;
|
;;
|
||||||
--profile)
|
--profile)
|
||||||
PROFILE="$2"
|
PROFILE="$2"
|
||||||
|
TARGET_PROFILE_DIR="$PROFILE"
|
||||||
shift 2
|
shift 2
|
||||||
if [ "$WARP_CHANNEL" = "local" ]; then
|
if [ "$WARP_CHANNEL" = "local" ]; then
|
||||||
WARP_APP_PATH="${TARGET_DIR}/${PROFILE}/bundle/osx/Galaxy Local.app"
|
WARP_APP_PATH="${TARGET_DIR}/${PROFILE}/bundle/osx/Galaxy Local.app"
|
||||||
@@ -127,6 +130,23 @@ if [ "${GENERATE_SCHEMA:-false}" != "true" ]; then
|
|||||||
fi
|
fi
|
||||||
NO_LICENSES=1 "${REPO_ROOT}/script/prepare_bundled_resources" "$WARP_APP_PATH/Contents/Resources" "$WARP_CHANNEL"
|
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"
|
"${REPO_ROOT}/script/compile_icon" "$WARP_CHANNEL" "$WARP_APP_PATH"
|
||||||
|
|
||||||
if [[ ",$FEATURES," =~ ",heap_usage_tracking," ]]; then
|
if [[ ",$FEATURES," =~ ",heap_usage_tracking," ]]; then
|
||||||
|
|||||||
Reference in New Issue
Block a user