Complete local-first Rig provider migration
This commit is contained in:
Generated
+34
-8
@@ -1537,6 +1537,32 @@ dependencies = [
|
|||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aws-sdk-bedrock"
|
||||||
|
version = "1.150.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "81186bb96a4e98ff93f7b4336deec0afc1f90ca282099fc5395fcf5de6c0389d"
|
||||||
|
dependencies = [
|
||||||
|
"arc-swap",
|
||||||
|
"aws-credential-types",
|
||||||
|
"aws-runtime",
|
||||||
|
"aws-smithy-async",
|
||||||
|
"aws-smithy-http",
|
||||||
|
"aws-smithy-json",
|
||||||
|
"aws-smithy-observability",
|
||||||
|
"aws-smithy-runtime",
|
||||||
|
"aws-smithy-runtime-api",
|
||||||
|
"aws-smithy-schema",
|
||||||
|
"aws-smithy-types",
|
||||||
|
"aws-types",
|
||||||
|
"bytes",
|
||||||
|
"fastrand 2.5.0",
|
||||||
|
"http 0.2.12",
|
||||||
|
"http 1.5.0",
|
||||||
|
"regex-lite",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aws-sdk-bedrockruntime"
|
name = "aws-sdk-bedrockruntime"
|
||||||
version = "1.138.0"
|
version = "1.138.0"
|
||||||
@@ -5647,6 +5673,7 @@ dependencies = [
|
|||||||
"async-trait",
|
"async-trait",
|
||||||
"aws-config",
|
"aws-config",
|
||||||
"aws-credential-types",
|
"aws-credential-types",
|
||||||
|
"aws-sdk-bedrock",
|
||||||
"aws-sdk-bedrockruntime",
|
"aws-sdk-bedrockruntime",
|
||||||
"aws-sdk-sts",
|
"aws-sdk-sts",
|
||||||
"aws-smithy-types",
|
"aws-smithy-types",
|
||||||
@@ -12848,9 +12875,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rig-bedrock"
|
name = "rig-bedrock"
|
||||||
version = "0.40.0"
|
version = "0.41.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "10e8ee8d206e78398eca2db97cb0cf27f43c903a99c60a730bc7a0cfeaf3ee83"
|
checksum = "9a40dec6d2833da0f482f97f105b846b46b82b8ab904b78f2365e263e016f09d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-stream",
|
"async-stream",
|
||||||
"aws-config",
|
"aws-config",
|
||||||
@@ -12864,14 +12891,15 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"tracing-futures",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rig-core"
|
name = "rig-core"
|
||||||
version = "0.40.0"
|
version = "0.41.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d8731dd5532b3a12ce1613af73073fb2051ef750f50c504778c21d55ae933cac"
|
checksum = "35f5520515ae8f6851adcbc6fde9eea8e96f657418c062e16c82cd81cce44e8e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"as-any",
|
"as-any",
|
||||||
"async-stream",
|
"async-stream",
|
||||||
@@ -12903,16 +12931,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rig-derive"
|
name = "rig-derive"
|
||||||
version = "0.40.0"
|
version = "0.41.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b3e98dde7a4e59e083e7396126ee4c83498c5bff605d126654e67815fa230a78"
|
checksum = "eb868fcebdf3ba425e3afad2e4926bb6d9e1188a856843b00bcee2e15c07424f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"convert_case 0.11.0",
|
"convert_case 0.11.0",
|
||||||
"indoc",
|
|
||||||
"proc-macro-crate 3.5.0",
|
"proc-macro-crate 3.5.0",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"serde_json",
|
|
||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -139,6 +139,7 @@ async-task = "4.2.0"
|
|||||||
async-trait = "0.1.89"
|
async-trait = "0.1.89"
|
||||||
async-fs = "2.1.2"
|
async-fs = "2.1.2"
|
||||||
aws-sdk-bedrockruntime = { version = "1", default-features = false, features = ["default-https-client", "rt-tokio"] }
|
aws-sdk-bedrockruntime = { version = "1", default-features = false, features = ["default-https-client", "rt-tokio"] }
|
||||||
|
aws-sdk-bedrock = { version = "1", default-features = false, features = ["default-https-client", "rt-tokio"] }
|
||||||
aws-smithy-http-client = { version = "1", features = ["test-util"] }
|
aws-smithy-http-client = { version = "1", features = ["test-util"] }
|
||||||
backtrace = "0.3.76"
|
backtrace = "0.3.76"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
@@ -261,8 +262,8 @@ reqwest = { version = "0.13", features = [
|
|||||||
"stream",
|
"stream",
|
||||||
] }
|
] }
|
||||||
reqwest-eventsource = { package = "aha-reqwest-eventsource", version = "0.1" }
|
reqwest-eventsource = { package = "aha-reqwest-eventsource", version = "0.1" }
|
||||||
rig-core = "=0.40.0"
|
rig-core = "=0.41.0"
|
||||||
rig-bedrock = "=0.40.0"
|
rig-bedrock = "=0.41.0"
|
||||||
resvg = "0.47.0"
|
resvg = "0.47.0"
|
||||||
rust-embed = { version = "8.7.0", features = ["include-exclude"] }
|
rust-embed = { version = "8.7.0", features = ["include-exclude"] }
|
||||||
rustc-hash = "2.1.1"
|
rustc-hash = "2.1.1"
|
||||||
|
|||||||
@@ -328,6 +328,7 @@ tracing-subscriber.workspace = true
|
|||||||
# AWS SDK (loading credentials for BYO LLM)
|
# AWS SDK (loading credentials for BYO LLM)
|
||||||
aws-config = { version = "1.8.16", features = ["credentials-login"] }
|
aws-config = { version = "1.8.16", features = ["credentials-login"] }
|
||||||
aws-credential-types = "1"
|
aws-credential-types = "1"
|
||||||
|
aws-sdk-bedrock.workspace = true
|
||||||
aws-sdk-bedrockruntime.workspace = true
|
aws-sdk-bedrockruntime.workspace = true
|
||||||
aws-sdk-sts = { version = "1", default-features = false, features = ["default-https-client", "rt-tokio"] }
|
aws-sdk-sts = { version = "1", default-features = false, features = ["default-https-client", "rt-tokio"] }
|
||||||
aws-smithy-types = "1"
|
aws-smithy-types = "1"
|
||||||
|
|||||||
@@ -86,17 +86,12 @@ impl AcpRuntimeModel {
|
|||||||
Ok(manager)
|
Ok(manager)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn discovery_config(settings: &AISettings) -> Result<AcpManagerConfig, String> {
|
pub(crate) fn discovery_config_for_values(
|
||||||
let agent_id = if settings.acp_agent_id.value().trim().is_empty() {
|
agent_id: &str,
|
||||||
"codex"
|
command: &str,
|
||||||
} else {
|
args: &[String],
|
||||||
settings.acp_agent_id.value().trim()
|
) -> Result<AcpManagerConfig, String> {
|
||||||
};
|
let launch = crate::ai::acp::resolve_acp_launch(agent_id, command, args)?;
|
||||||
let launch = crate::ai::acp::resolve_acp_launch(
|
|
||||||
agent_id,
|
|
||||||
settings.acp_agent_command.value(),
|
|
||||||
settings.acp_agent_args.value(),
|
|
||||||
)?;
|
|
||||||
Ok(AcpManagerConfig::new(launch))
|
Ok(AcpManagerConfig::new(launch))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
//! AWS Bedrock control-plane discovery.
|
||||||
|
//!
|
||||||
|
//! The foundation-model catalog is only a candidate list. Every candidate is
|
||||||
|
//! checked with `GetFoundationModelAvailability` before it is offered to the
|
||||||
|
//! user or persisted in Galaxy settings.
|
||||||
|
|
||||||
|
use aws_config::BehaviorVersion;
|
||||||
|
use aws_sdk_bedrock::Client;
|
||||||
|
use aws_sdk_bedrockruntime::config::Region;
|
||||||
|
|
||||||
|
use super::client::{BedrockClientConfig, BedrockError};
|
||||||
|
use crate::settings::ai::BedrockModelConfig;
|
||||||
|
|
||||||
|
pub async fn discover_available_models(
|
||||||
|
config: BedrockClientConfig,
|
||||||
|
) -> Result<Vec<BedrockModelConfig>, String> {
|
||||||
|
let aws_config = load_aws_config(&config)
|
||||||
|
.await
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
let client = Client::new(&aws_config);
|
||||||
|
let catalog = client
|
||||||
|
.list_foundation_models()
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|error| format!("Could not list AWS Bedrock foundation models: {error}"))?;
|
||||||
|
|
||||||
|
let mut models = Vec::new();
|
||||||
|
for summary in catalog.model_summaries() {
|
||||||
|
let model_id = summary.model_id();
|
||||||
|
let availability = match client
|
||||||
|
.get_foundation_model_availability()
|
||||||
|
.model_id(model_id)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(availability) => availability,
|
||||||
|
Err(error) => {
|
||||||
|
log::debug!(
|
||||||
|
"[bedrock] Availability check failed for {model_id}; excluding model: {error}"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if !model_availability_is_usable(
|
||||||
|
availability
|
||||||
|
.agreement_availability()
|
||||||
|
.map(|agreement| agreement.status().as_str()),
|
||||||
|
availability.authorization_status().as_str(),
|
||||||
|
availability.entitlement_availability().as_str(),
|
||||||
|
availability.region_availability().as_str(),
|
||||||
|
) {
|
||||||
|
log::debug!(
|
||||||
|
"[bedrock] Excluding {model_id}: agreement={}, authorization={}, entitlement={}, region={}",
|
||||||
|
availability
|
||||||
|
.agreement_availability()
|
||||||
|
.map(|agreement| agreement.status().as_str())
|
||||||
|
.unwrap_or("MISSING"),
|
||||||
|
availability.authorization_status().as_str(),
|
||||||
|
availability.entitlement_availability().as_str(),
|
||||||
|
availability.region_availability().as_str(),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let display_name = summary
|
||||||
|
.model_name()
|
||||||
|
.map(str::to_owned)
|
||||||
|
.unwrap_or_else(|| prettify_model_id(model_id));
|
||||||
|
let vision_supported = summary
|
||||||
|
.input_modalities()
|
||||||
|
.iter()
|
||||||
|
.any(|modality| modality.as_str() == "IMAGE");
|
||||||
|
|
||||||
|
models.push(BedrockModelConfig {
|
||||||
|
model_id: model_id.to_owned(),
|
||||||
|
display_name,
|
||||||
|
vision_supported,
|
||||||
|
use_rig: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
models.sort_by(|left, right| left.display_name.cmp(&right.display_name));
|
||||||
|
if models.is_empty() {
|
||||||
|
return Err(
|
||||||
|
"AWS returned no Bedrock models that are authorized and available in this region."
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(models)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn model_availability_is_usable(
|
||||||
|
agreement_status: Option<&str>,
|
||||||
|
authorization_status: &str,
|
||||||
|
entitlement_status: &str,
|
||||||
|
region_status: &str,
|
||||||
|
) -> bool {
|
||||||
|
agreement_status == Some("AVAILABLE")
|
||||||
|
&& authorization_status == "AUTHORIZED"
|
||||||
|
&& entitlement_status == "AVAILABLE"
|
||||||
|
&& region_status == "AVAILABLE"
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_aws_config(
|
||||||
|
config: &BedrockClientConfig,
|
||||||
|
) -> Result<aws_config::SdkConfig, BedrockError> {
|
||||||
|
let sdk_config = match config.auth_method {
|
||||||
|
crate::settings::ai::BedrockAuthMethod::Profile
|
||||||
|
| crate::settings::ai::BedrockAuthMethod::Sso => {
|
||||||
|
let mut loader = aws_config::defaults(BehaviorVersion::latest());
|
||||||
|
if !config.profile.is_empty() && config.profile != "default" {
|
||||||
|
loader = loader.profile_name(&config.profile);
|
||||||
|
}
|
||||||
|
if !config.region.is_empty() {
|
||||||
|
loader = loader.region(Region::new(config.region.clone()));
|
||||||
|
}
|
||||||
|
loader.load().await
|
||||||
|
}
|
||||||
|
crate::settings::ai::BedrockAuthMethod::StaticKeys => {
|
||||||
|
if config.access_key_id.is_empty() || config.secret_access_key.is_empty() {
|
||||||
|
return Err(BedrockError::CredentialsNotConfigured);
|
||||||
|
}
|
||||||
|
let credentials = aws_credential_types::Credentials::new(
|
||||||
|
&config.access_key_id,
|
||||||
|
&config.secret_access_key,
|
||||||
|
config.session_token.clone(),
|
||||||
|
None,
|
||||||
|
"galaxy-bedrock-discovery",
|
||||||
|
);
|
||||||
|
let mut loader =
|
||||||
|
aws_config::defaults(BehaviorVersion::latest()).credentials_provider(credentials);
|
||||||
|
loader = loader.region(Region::new(if config.region.is_empty() {
|
||||||
|
"us-east-1".to_string()
|
||||||
|
} else {
|
||||||
|
config.region.clone()
|
||||||
|
}));
|
||||||
|
loader.load().await
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if sdk_config.region().is_none() {
|
||||||
|
return Err(BedrockError::RegionNotConfigured);
|
||||||
|
}
|
||||||
|
Ok(sdk_config)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prettify_model_id(model_id: &str) -> String {
|
||||||
|
model_id
|
||||||
|
.rsplit('.')
|
||||||
|
.next()
|
||||||
|
.unwrap_or(model_id)
|
||||||
|
.replace(['-', ':'], " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::model_availability_is_usable;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn requires_every_availability_status() {
|
||||||
|
assert!(model_availability_is_usable(
|
||||||
|
Some("AVAILABLE"),
|
||||||
|
"AUTHORIZED",
|
||||||
|
"AVAILABLE",
|
||||||
|
"AVAILABLE",
|
||||||
|
));
|
||||||
|
assert!(!model_availability_is_usable(
|
||||||
|
None,
|
||||||
|
"AUTHORIZED",
|
||||||
|
"AVAILABLE",
|
||||||
|
"AVAILABLE",
|
||||||
|
));
|
||||||
|
assert!(!model_availability_is_usable(
|
||||||
|
Some("AVAILABLE"),
|
||||||
|
"NOT_AUTHORIZED",
|
||||||
|
"AVAILABLE",
|
||||||
|
"AVAILABLE",
|
||||||
|
));
|
||||||
|
assert!(!model_availability_is_usable(
|
||||||
|
Some("AVAILABLE"),
|
||||||
|
"AUTHORIZED",
|
||||||
|
"NOT_AVAILABLE",
|
||||||
|
"AVAILABLE",
|
||||||
|
));
|
||||||
|
assert!(!model_availability_is_usable(
|
||||||
|
Some("AVAILABLE"),
|
||||||
|
"AUTHORIZED",
|
||||||
|
"AVAILABLE",
|
||||||
|
"NOT_AVAILABLE",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ pub mod client;
|
|||||||
pub mod convert;
|
pub mod convert;
|
||||||
pub mod crash_log;
|
pub mod crash_log;
|
||||||
pub mod diagnostic;
|
pub mod diagnostic;
|
||||||
|
pub mod discovery;
|
||||||
pub mod external_config;
|
pub mod external_config;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
pub mod request_translator;
|
pub mod request_translator;
|
||||||
|
|||||||
@@ -1,156 +1,7 @@
|
|||||||
#![allow(dead_code)]
|
#![allow(dead_code)]
|
||||||
|
|
||||||
use super::external_config::ExternalBedrockConfig;
|
|
||||||
use crate::settings::ai::BedrockModelConfig;
|
use crate::settings::ai::BedrockModelConfig;
|
||||||
|
|
||||||
pub struct DefaultModel {
|
|
||||||
pub model_id: &'static str,
|
|
||||||
pub display_name: &'static str,
|
|
||||||
pub vision_supported: bool,
|
|
||||||
pub context_size: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub const DEFAULT_BEDROCK_MODELS: &[DefaultModel] = &[
|
|
||||||
DefaultModel {
|
|
||||||
model_id: "us.anthropic.claude-opus-4-6-v1[1m]",
|
|
||||||
display_name: "Claude Opus 4.6 (1M)",
|
|
||||||
vision_supported: true,
|
|
||||||
context_size: 1_000_000,
|
|
||||||
},
|
|
||||||
DefaultModel {
|
|
||||||
model_id: "us.anthropic.claude-sonnet-4-6[1m]",
|
|
||||||
display_name: "Claude Sonnet 4.6 (1M)",
|
|
||||||
vision_supported: true,
|
|
||||||
context_size: 1_000_000,
|
|
||||||
},
|
|
||||||
DefaultModel {
|
|
||||||
model_id: "us.anthropic.claude-sonnet-4-5-20250929-v1:0",
|
|
||||||
display_name: "Claude Sonnet 4.5",
|
|
||||||
vision_supported: true,
|
|
||||||
context_size: 200_000,
|
|
||||||
},
|
|
||||||
DefaultModel {
|
|
||||||
model_id: "us.anthropic.claude-sonnet-4-20250514-v1:0",
|
|
||||||
display_name: "Claude Sonnet 4",
|
|
||||||
vision_supported: true,
|
|
||||||
context_size: 200_000,
|
|
||||||
},
|
|
||||||
DefaultModel {
|
|
||||||
model_id: "us.anthropic.claude-3-sonnet-20240229-v1:0",
|
|
||||||
display_name: "Claude 3 Sonnet",
|
|
||||||
vision_supported: true,
|
|
||||||
context_size: 200_000,
|
|
||||||
},
|
|
||||||
DefaultModel {
|
|
||||||
model_id: "global.anthropic.claude-sonnet-4-6",
|
|
||||||
display_name: "Claude Sonnet 4.6 (Global)",
|
|
||||||
vision_supported: true,
|
|
||||||
context_size: 200_000,
|
|
||||||
},
|
|
||||||
DefaultModel {
|
|
||||||
model_id: "global.anthropic.claude-sonnet-4-5-20250929-v1:0",
|
|
||||||
display_name: "Claude Sonnet 4.5 (Global)",
|
|
||||||
vision_supported: true,
|
|
||||||
context_size: 200_000,
|
|
||||||
},
|
|
||||||
DefaultModel {
|
|
||||||
model_id: "global.anthropic.claude-sonnet-4-20250514-v1:0",
|
|
||||||
display_name: "Claude Sonnet 4 (Global)",
|
|
||||||
vision_supported: true,
|
|
||||||
context_size: 200_000,
|
|
||||||
},
|
|
||||||
DefaultModel {
|
|
||||||
model_id: "us.anthropic.claude-opus-4-5-20251101-v1:0",
|
|
||||||
display_name: "Claude Opus 4.5",
|
|
||||||
vision_supported: true,
|
|
||||||
context_size: 200_000,
|
|
||||||
},
|
|
||||||
DefaultModel {
|
|
||||||
model_id: "us.anthropic.claude-opus-4-1-20250805-v1:0",
|
|
||||||
display_name: "Claude Opus 4.1",
|
|
||||||
vision_supported: true,
|
|
||||||
context_size: 200_000,
|
|
||||||
},
|
|
||||||
DefaultModel {
|
|
||||||
model_id: "global.anthropic.claude-opus-4-6-v1",
|
|
||||||
display_name: "Claude Opus 4.6 (Global)",
|
|
||||||
vision_supported: true,
|
|
||||||
context_size: 200_000,
|
|
||||||
},
|
|
||||||
DefaultModel {
|
|
||||||
model_id: "global.anthropic.claude-opus-4-5-20251101-v1:0",
|
|
||||||
display_name: "Claude Opus 4.5 (Global)",
|
|
||||||
vision_supported: true,
|
|
||||||
context_size: 200_000,
|
|
||||||
},
|
|
||||||
DefaultModel {
|
|
||||||
model_id: "us.anthropic.claude-haiku-4-5-20251001-v1:0",
|
|
||||||
display_name: "Claude Haiku 4.5",
|
|
||||||
vision_supported: true,
|
|
||||||
context_size: 200_000,
|
|
||||||
},
|
|
||||||
DefaultModel {
|
|
||||||
model_id: "us.anthropic.claude-3-haiku-20240307-v1:0",
|
|
||||||
display_name: "Claude 3 Haiku",
|
|
||||||
vision_supported: true,
|
|
||||||
context_size: 200_000,
|
|
||||||
},
|
|
||||||
DefaultModel {
|
|
||||||
model_id: "us.anthropic.claude-3-5-haiku-20241022-v1:0",
|
|
||||||
display_name: "Claude 3.5 Haiku",
|
|
||||||
vision_supported: true,
|
|
||||||
context_size: 200_000,
|
|
||||||
},
|
|
||||||
DefaultModel {
|
|
||||||
model_id: "global.anthropic.claude-haiku-4-5-20251001-v1:0",
|
|
||||||
display_name: "Claude Haiku 4.5 (Global)",
|
|
||||||
vision_supported: true,
|
|
||||||
context_size: 200_000,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
pub fn get_effective_models(user_models: &[BedrockModelConfig]) -> Vec<BedrockModelConfig> {
|
|
||||||
if !user_models.is_empty() {
|
|
||||||
return user_models.to_vec();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fall back to models from external configs (Claude Code / OpenCode)
|
|
||||||
let external = ExternalBedrockConfig::load();
|
|
||||||
if !external.models.is_empty() {
|
|
||||||
log::info!(
|
|
||||||
"[bedrock] Using {} model(s) from external config",
|
|
||||||
external.models.len()
|
|
||||||
);
|
|
||||||
// Merge external models with defaults so the user still sees all defaults
|
|
||||||
let mut models = external.models;
|
|
||||||
let defaults: Vec<BedrockModelConfig> = DEFAULT_BEDROCK_MODELS
|
|
||||||
.iter()
|
|
||||||
.map(|m| BedrockModelConfig {
|
|
||||||
model_id: m.model_id.to_string(),
|
|
||||||
display_name: m.display_name.to_string(),
|
|
||||||
vision_supported: m.vision_supported,
|
|
||||||
use_rig: false,
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
for default in defaults {
|
|
||||||
if !models.iter().any(|m| m.model_id == default.model_id) {
|
|
||||||
models.push(default);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return models;
|
|
||||||
}
|
|
||||||
|
|
||||||
DEFAULT_BEDROCK_MODELS
|
|
||||||
.iter()
|
|
||||||
.map(|m| BedrockModelConfig {
|
|
||||||
model_id: m.model_id.to_string(),
|
|
||||||
display_name: m.display_name.to_string(),
|
|
||||||
vision_supported: m.vision_supported,
|
|
||||||
use_rig: false,
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn configured_model_uses_rig(
|
pub fn configured_model_uses_rig(
|
||||||
selected_model_id: &str,
|
selected_model_id: &str,
|
||||||
configured_models: &[BedrockModelConfig],
|
configured_models: &[BedrockModelConfig],
|
||||||
|
|||||||
@@ -78,27 +78,6 @@ fn test_cross_region_prefix_unknown_region() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_get_effective_models_empty_returns_defaults() {
|
|
||||||
let models = get_effective_models(&[]);
|
|
||||||
assert_eq!(models.len(), DEFAULT_BEDROCK_MODELS.len());
|
|
||||||
assert_eq!(models[0].model_id, "us.anthropic.claude-opus-4-6-v1[1m]");
|
|
||||||
assert_eq!(models[0].display_name, "Claude Opus 4.6 (1M)");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_get_effective_models_custom_overrides() {
|
|
||||||
let custom = vec![BedrockModelConfig {
|
|
||||||
model_id: "custom.model-v1:0".to_string(),
|
|
||||||
display_name: "Custom Model".to_string(),
|
|
||||||
vision_supported: false,
|
|
||||||
use_rig: true,
|
|
||||||
}];
|
|
||||||
let models = get_effective_models(&custom);
|
|
||||||
assert_eq!(models.len(), 1);
|
|
||||||
assert_eq!(models[0].model_id, "custom.model-v1:0");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_cross_region_prefix_skips_arn() {
|
fn test_cross_region_prefix_skips_arn() {
|
||||||
let arn = "arn:aws:bedrock:us-east-1:156729649053:application-inference-profile/fma5bsw4oxhy";
|
let arn = "arn:aws:bedrock:us-east-1:156729649053:application-inference-profile/fma5bsw4oxhy";
|
||||||
|
|||||||
@@ -227,9 +227,14 @@ impl ResponseStream {
|
|||||||
let llm_prefs = LLMPreferences::as_ref(ctx);
|
let llm_prefs = LLMPreferences::as_ref(ctx);
|
||||||
if let Some(client_config) = llm_prefs.openai_client_config_for_model(model_id) {
|
if let Some(client_config) = llm_prefs.openai_client_config_for_model(model_id) {
|
||||||
return ProviderConfig::OpenAI(OpenAIClientConfig {
|
return ProviderConfig::OpenAI(OpenAIClientConfig {
|
||||||
|
kind: client_config.kind,
|
||||||
base_url: client_config.base_url.clone(),
|
base_url: client_config.base_url.clone(),
|
||||||
api_key: client_config.api_key.clone(),
|
api_key: client_config.api_key.clone(),
|
||||||
model: Some(model_id.to_string()),
|
model: client_config
|
||||||
|
.model
|
||||||
|
.clone()
|
||||||
|
.or_else(|| Some(model_id.to_string())),
|
||||||
|
reasoning_effort: client_config.reasoning_effort.clone(),
|
||||||
max_input_tokens: client_config.max_input_tokens,
|
max_input_tokens: client_config.max_input_tokens,
|
||||||
max_output_tokens: client_config.max_output_tokens,
|
max_output_tokens: client_config.max_output_tokens,
|
||||||
use_rig: client_config.use_rig,
|
use_rig: client_config.use_rig,
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
//! ChatGPT subscription OAuth state used by the AI settings page.
|
||||||
|
|
||||||
|
use async_channel::unbounded;
|
||||||
|
use galaxy_agent_rig::{ChatGPTDeviceCode, ChatGPTSubscriptionClient};
|
||||||
|
use galaxyui::{Entity, ModelContext, SingletonEntity};
|
||||||
|
|
||||||
|
/// Current state of the local ChatGPT subscription connection.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub(crate) enum ChatGPTAuthState {
|
||||||
|
NotConnected,
|
||||||
|
Connecting,
|
||||||
|
AwaitingDeviceCode {
|
||||||
|
verification_uri: String,
|
||||||
|
user_code: String,
|
||||||
|
},
|
||||||
|
Connected,
|
||||||
|
Failed(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ChatGPTAuthEvent {
|
||||||
|
DeviceCode(ChatGPTDeviceCode),
|
||||||
|
Completed(Result<(), String>),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub(crate) enum ChatGPTAuthModelEvent {
|
||||||
|
StateChanged,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Coordinates Rig's device authorization flow with Galaxy UI.
|
||||||
|
pub(crate) struct ChatGPTAuthModel {
|
||||||
|
state: ChatGPTAuthState,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChatGPTAuthModel {
|
||||||
|
pub(crate) fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
state: ChatGPTAuthState::NotConnected,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn state(&self) -> &ChatGPTAuthState {
|
||||||
|
&self.state
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn connect(&mut self, ctx: &mut ModelContext<Self>) {
|
||||||
|
if matches!(
|
||||||
|
self.state,
|
||||||
|
ChatGPTAuthState::Connecting | ChatGPTAuthState::AwaitingDeviceCode { .. }
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.state = ChatGPTAuthState::Connecting;
|
||||||
|
ctx.emit(ChatGPTAuthModelEvent::StateChanged);
|
||||||
|
|
||||||
|
let (event_tx, event_rx) = unbounded();
|
||||||
|
let device_code_tx = event_tx.clone();
|
||||||
|
let _ = ctx.spawn_stream_local(
|
||||||
|
event_rx,
|
||||||
|
|model, event, ctx| {
|
||||||
|
match event {
|
||||||
|
ChatGPTAuthEvent::DeviceCode(code) => {
|
||||||
|
model.state = ChatGPTAuthState::AwaitingDeviceCode {
|
||||||
|
verification_uri: code.verification_uri,
|
||||||
|
user_code: code.user_code,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
ChatGPTAuthEvent::Completed(result) => {
|
||||||
|
model.state = match result {
|
||||||
|
Ok(()) => ChatGPTAuthState::Connected,
|
||||||
|
Err(error) => ChatGPTAuthState::Failed(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.emit(ChatGPTAuthModelEvent::StateChanged);
|
||||||
|
},
|
||||||
|
|_, _| {},
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = ctx.spawn(
|
||||||
|
async move {
|
||||||
|
let result =
|
||||||
|
match ChatGPTSubscriptionClient::with_device_code_handler(move |code| {
|
||||||
|
let _ = device_code_tx.try_send(ChatGPTAuthEvent::DeviceCode(code));
|
||||||
|
}) {
|
||||||
|
Ok(client) => client.authorize().await,
|
||||||
|
Err(error) => Err(error),
|
||||||
|
};
|
||||||
|
let _ = event_tx.send(ChatGPTAuthEvent::Completed(result)).await;
|
||||||
|
},
|
||||||
|
|_, _, _| {},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Entity for ChatGPTAuthModel {
|
||||||
|
type Event = ChatGPTAuthModelEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SingletonEntity for ChatGPTAuthModel {}
|
||||||
@@ -14,6 +14,7 @@ use crate::ai::agent::conversation::AIConversationId;
|
|||||||
use crate::ai::llms::LLMPreferences;
|
use crate::ai::llms::LLMPreferences;
|
||||||
use crate::ai::openai::client::{OpenAIClient, OpenAIClientConfig};
|
use crate::ai::openai::client::{OpenAIClient, OpenAIClientConfig};
|
||||||
use crate::ai::provider::ProviderConfig;
|
use crate::ai::provider::ProviderConfig;
|
||||||
|
use crate::settings::OpenAIProviderKind;
|
||||||
use crate::AISettings;
|
use crate::AISettings;
|
||||||
|
|
||||||
/// Maximum default iterations if the setting is somehow zero.
|
/// Maximum default iterations if the setting is somehow zero.
|
||||||
@@ -163,9 +164,14 @@ impl CrosscheckReviewer {
|
|||||||
let llm_prefs = LLMPreferences::as_ref(ctx);
|
let llm_prefs = LLMPreferences::as_ref(ctx);
|
||||||
if let Some(client_config) = llm_prefs.openai_client_config_for_model(model_id) {
|
if let Some(client_config) = llm_prefs.openai_client_config_for_model(model_id) {
|
||||||
return ProviderConfig::OpenAI(OpenAIClientConfig {
|
return ProviderConfig::OpenAI(OpenAIClientConfig {
|
||||||
|
kind: client_config.kind,
|
||||||
base_url: client_config.base_url.clone(),
|
base_url: client_config.base_url.clone(),
|
||||||
api_key: client_config.api_key.clone(),
|
api_key: client_config.api_key.clone(),
|
||||||
model: Some(model_id.to_string()),
|
model: client_config
|
||||||
|
.model
|
||||||
|
.clone()
|
||||||
|
.or_else(|| Some(model_id.to_string())),
|
||||||
|
reasoning_effort: client_config.reasoning_effort.clone(),
|
||||||
max_input_tokens: client_config.max_input_tokens,
|
max_input_tokens: client_config.max_input_tokens,
|
||||||
max_output_tokens: Some(REVIEWER_MAX_OUTPUT_TOKENS),
|
max_output_tokens: Some(REVIEWER_MAX_OUTPUT_TOKENS),
|
||||||
use_rig: client_config.use_rig,
|
use_rig: client_config.use_rig,
|
||||||
@@ -217,6 +223,26 @@ impl CrosscheckReviewer {
|
|||||||
provider_config: ProviderConfig,
|
provider_config: ProviderConfig,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
match provider_config {
|
match provider_config {
|
||||||
|
ProviderConfig::OpenAI(config)
|
||||||
|
if config.kind == OpenAIProviderKind::ChatGPTSubscription =>
|
||||||
|
{
|
||||||
|
let runtime = galaxy_agent_rig::ChatGPTSubscriptionRuntime::new(
|
||||||
|
galaxy_agent_rig::ChatGPTSubscriptionRuntimeConfig {
|
||||||
|
model: config.model.unwrap_or(model_id),
|
||||||
|
reasoning_effort: config.reasoning_effort,
|
||||||
|
max_output_tokens: Some(u64::from(REVIEWER_MAX_OUTPUT_TOKENS)),
|
||||||
|
auth_file: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
runtime
|
||||||
|
.complete_text(
|
||||||
|
prompt::CROSSCHECK_REVIEWER_SYSTEM_PROMPT.to_string(),
|
||||||
|
format!(
|
||||||
|
"Please review the following agent output:\n\n---\n\n{agent_output}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
ProviderConfig::OpenAI(config) => {
|
ProviderConfig::OpenAI(config) => {
|
||||||
Self::invoke_via_openai(agent_output, model_id, config).await
|
Self::invoke_via_openai(agent_output, model_id, config).await
|
||||||
}
|
}
|
||||||
|
|||||||
+237
-79
@@ -17,14 +17,16 @@ 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_selection_identity};
|
||||||
use crate::ai::bedrock::models::get_effective_models;
|
|
||||||
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};
|
||||||
#[cfg(not(target_family = "wasm"))]
|
#[cfg(not(target_family = "wasm"))]
|
||||||
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::{AcpConfigValueSettings, BedrockModelConfig, OpenAIModelConfig};
|
use crate::settings::{
|
||||||
|
AcpConfigValueSettings, BedrockModelConfig, OpenAIModelConfig, 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};
|
||||||
use crate::{report_error, AISettings};
|
use crate::{report_error, AISettings};
|
||||||
@@ -659,6 +661,7 @@ impl LLMPreferences {
|
|||||||
| AISettingsChangedEvent::OpenAIProviders { .. }
|
| AISettingsChangedEvent::OpenAIProviders { .. }
|
||||||
| AISettingsChangedEvent::AcpAgents { .. }
|
| AISettingsChangedEvent::AcpAgents { .. }
|
||||||
| AISettingsChangedEvent::AcpAgentId { .. }
|
| AISettingsChangedEvent::AcpAgentId { .. }
|
||||||
|
| AISettingsChangedEvent::BedrockModels { .. }
|
||||||
) {
|
) {
|
||||||
me.inject_bedrock_models(ctx);
|
me.inject_bedrock_models(ctx);
|
||||||
me.inject_openai_models(ctx);
|
me.inject_openai_models(ctx);
|
||||||
@@ -670,6 +673,11 @@ impl LLMPreferences {
|
|||||||
) {
|
) {
|
||||||
me.fetch_openai_models_from_endpoint(ctx);
|
me.fetch_openai_models_from_endpoint(ctx);
|
||||||
}
|
}
|
||||||
|
if matches!(event, AISettingsChangedEvent::BedrockEnabled { .. })
|
||||||
|
&& *AISettings::as_ref(ctx).bedrock_enabled.value()
|
||||||
|
{
|
||||||
|
me.refresh_bedrock_models(ctx);
|
||||||
|
}
|
||||||
// Safety: ensure the default model is still present in choices.
|
// Safety: ensure the default model is still present in choices.
|
||||||
// If all provider models were removed, the default_id would dangle.
|
// If all provider models were removed, the default_id would dangle.
|
||||||
me.ensure_default_model_present();
|
me.ensure_default_model_present();
|
||||||
@@ -709,8 +717,8 @@ impl LLMPreferences {
|
|||||||
|
|
||||||
#[cfg(not(target_family = "wasm"))]
|
#[cfg(not(target_family = "wasm"))]
|
||||||
{
|
{
|
||||||
Self::ensure_default_models_in_settings(ctx);
|
Self::ensure_default_chatgpt_models_in_settings(ctx);
|
||||||
me.inject_bedrock_models(ctx);
|
me.refresh_bedrock_models(ctx);
|
||||||
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);
|
||||||
@@ -720,39 +728,85 @@ impl LLMPreferences {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(target_family = "wasm"))]
|
#[cfg(not(target_family = "wasm"))]
|
||||||
fn ensure_default_models_in_settings(ctx: &mut ModelContext<Self>) {
|
fn ensure_default_chatgpt_models_in_settings(ctx: &mut ModelContext<Self>) {
|
||||||
use crate::ai::bedrock::models::DEFAULT_BEDROCK_MODELS;
|
let mut providers = AISettings::as_ref(ctx).openai_providers.value().clone();
|
||||||
|
let default_chatgpt_models = crate::settings::ai::default_chatgpt_provider().models;
|
||||||
|
let mut providers_changed = false;
|
||||||
|
for provider in &mut providers {
|
||||||
|
if provider.kind != OpenAIProviderKind::ChatGPTSubscription {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
let settings = AISettings::as_ref(ctx);
|
for default_model in &default_chatgpt_models {
|
||||||
let mut current_models: Vec<BedrockModelConfig> = settings.bedrock_models.value().clone();
|
if !provider
|
||||||
|
.models
|
||||||
|
.iter()
|
||||||
|
.any(|model| model.model_id == default_model.model_id)
|
||||||
|
{
|
||||||
|
provider.models.push(default_model.clone());
|
||||||
|
providers_changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let existing_ids: std::collections::HashSet<String> =
|
for model in &mut provider.models {
|
||||||
current_models.iter().map(|m| m.model_id.clone()).collect();
|
if !model.reasoning_efforts.is_empty() {
|
||||||
|
continue;
|
||||||
let mut added = false;
|
}
|
||||||
for default in DEFAULT_BEDROCK_MODELS {
|
if let Some(default_model) = default_chatgpt_models
|
||||||
if !existing_ids.contains(default.model_id as &str) {
|
.iter()
|
||||||
current_models.push(BedrockModelConfig {
|
.find(|default_model| default_model.model_id == model.model_id)
|
||||||
model_id: default.model_id.to_string(),
|
{
|
||||||
display_name: default.display_name.to_string(),
|
if !default_model.reasoning_efforts.is_empty() {
|
||||||
vision_supported: default.vision_supported,
|
model.reasoning_efforts = default_model.reasoning_efforts.clone();
|
||||||
use_rig: false,
|
providers_changed = true;
|
||||||
});
|
}
|
||||||
added = true;
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if providers_changed {
|
||||||
if added {
|
|
||||||
log::info!(
|
|
||||||
"[bedrock] Added missing default models to settings — now {} total",
|
|
||||||
current_models.len()
|
|
||||||
);
|
|
||||||
AISettings::handle(ctx).update(ctx, |settings, ctx| {
|
AISettings::handle(ctx).update(ctx, |settings, ctx| {
|
||||||
let _ = settings.bedrock_models.set_value(current_models, ctx);
|
let _ = settings.openai_providers.set_value(providers, ctx);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_family = "wasm"))]
|
||||||
|
fn refresh_bedrock_models(&mut self, ctx: &mut ModelContext<Self>) {
|
||||||
|
let settings = AISettings::as_ref(ctx);
|
||||||
|
if !*settings.bedrock_enabled.value() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let config = crate::ai::bedrock::client::BedrockClientConfig {
|
||||||
|
auth_method: *settings.bedrock_auth_method.value(),
|
||||||
|
profile: settings.bedrock_profile.value().clone(),
|
||||||
|
region: settings.bedrock_region.value().clone(),
|
||||||
|
access_key_id: settings.bedrock_access_key_id.value().clone(),
|
||||||
|
secret_access_key: settings.bedrock_secret_access_key.value().clone(),
|
||||||
|
session_token: None,
|
||||||
|
cross_region_inference: *settings.bedrock_cross_region_inference.value(),
|
||||||
|
use_rig: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let _ = ctx.spawn(
|
||||||
|
async move { crate::ai::bedrock::discovery::discover_available_models(config).await },
|
||||||
|
|me, result, ctx| match result {
|
||||||
|
Ok(models) => {
|
||||||
|
AISettings::handle(ctx).update(ctx, |settings, ctx| {
|
||||||
|
if let Err(error) = settings.bedrock_models.set_value(models, ctx) {
|
||||||
|
log::warn!("[bedrock] Failed to persist discovered models: {error}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
me.inject_bedrock_models(ctx);
|
||||||
|
me.ensure_default_model_present();
|
||||||
|
ctx.emit(LLMPreferencesEvent::UpdatedAvailableLLMs);
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
log::debug!("[bedrock] Startup model discovery unavailable: {error}");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(not(target_family = "wasm"))]
|
#[cfg(not(target_family = "wasm"))]
|
||||||
fn inject_bedrock_models(&mut self, ctx: &AppContext) {
|
fn inject_bedrock_models(&mut self, ctx: &AppContext) {
|
||||||
// Galaxy's runtime inventory is rebuilt exclusively from enabled local
|
// Galaxy's runtime inventory is rebuilt exclusively from enabled local
|
||||||
@@ -768,7 +822,9 @@ impl LLMPreferences {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let user_models: Vec<BedrockModelConfig> = settings.bedrock_models.value().clone();
|
// Bedrock models are populated only by the control-plane discovery
|
||||||
|
// flow. Never fall back to a static catalog or external config here.
|
||||||
|
let discovered_models: Vec<BedrockModelConfig> = settings.bedrock_models.value().clone();
|
||||||
let region = settings.bedrock_region.value().clone();
|
let region = settings.bedrock_region.value().clone();
|
||||||
let cross_region = *settings.bedrock_cross_region_inference.value();
|
let cross_region = *settings.bedrock_cross_region_inference.value();
|
||||||
|
|
||||||
@@ -777,7 +833,7 @@ impl LLMPreferences {
|
|||||||
let external_config = ExternalBedrockConfig::load();
|
let external_config = ExternalBedrockConfig::load();
|
||||||
let require_1h_cache = external_config.enable_prompt_caching_1h;
|
let require_1h_cache = external_config.enable_prompt_caching_1h;
|
||||||
|
|
||||||
let mut effective = get_effective_models(&user_models);
|
let mut effective = discovered_models;
|
||||||
|
|
||||||
// Filter out models that don't support 1-hour caching if required
|
// Filter out models that don't support 1-hour caching if required
|
||||||
if require_1h_cache {
|
if require_1h_cache {
|
||||||
@@ -943,8 +999,15 @@ impl LLMPreferences {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut provider_entries: Vec<(String, String, Option<String>, Vec<OpenAIModelConfig>)> =
|
type OpenAIProviderEntry = (
|
||||||
Vec::new();
|
String,
|
||||||
|
OpenAIProviderKind,
|
||||||
|
bool,
|
||||||
|
String,
|
||||||
|
Option<String>,
|
||||||
|
Vec<OpenAIModelConfig>,
|
||||||
|
);
|
||||||
|
let mut provider_entries: Vec<OpenAIProviderEntry> = Vec::new();
|
||||||
|
|
||||||
let configured_models = settings.openai_models.value().clone();
|
let configured_models = settings.openai_models.value().clone();
|
||||||
let single_provider_models = if configured_models.is_empty() {
|
let single_provider_models = if configured_models.is_empty() {
|
||||||
@@ -968,7 +1031,14 @@ impl LLMPreferences {
|
|||||||
} else {
|
} else {
|
||||||
"LiteLLM".to_string()
|
"LiteLLM".to_string()
|
||||||
};
|
};
|
||||||
provider_entries.push((name, base_url, api_key, single_provider_models));
|
provider_entries.push((
|
||||||
|
name,
|
||||||
|
OpenAIProviderKind::OpenAICompatible,
|
||||||
|
true,
|
||||||
|
base_url,
|
||||||
|
api_key,
|
||||||
|
single_provider_models,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
provider_entries.extend(
|
provider_entries.extend(
|
||||||
@@ -977,11 +1047,17 @@ impl LLMPreferences {
|
|||||||
.value()
|
.value()
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|provider| {
|
.filter_map(|provider| {
|
||||||
if provider.base_url.trim().is_empty() || provider.models.is_empty() {
|
if !provider.enabled
|
||||||
|
|| (provider.kind == OpenAIProviderKind::OpenAICompatible
|
||||||
|
&& provider.base_url.trim().is_empty())
|
||||||
|
|| provider.models.is_empty()
|
||||||
|
{
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
Some((
|
Some((
|
||||||
provider.name.clone(),
|
provider.name.clone(),
|
||||||
|
provider.kind,
|
||||||
|
provider.enabled,
|
||||||
provider.base_url.clone(),
|
provider.base_url.clone(),
|
||||||
provider.api_key.clone(),
|
provider.api_key.clone(),
|
||||||
provider.models.clone(),
|
provider.models.clone(),
|
||||||
@@ -995,58 +1071,93 @@ impl LLMPreferences {
|
|||||||
|
|
||||||
let mut total_injected = 0;
|
let mut total_injected = 0;
|
||||||
let mut seen_model_ids: HashSet<String> = HashSet::new();
|
let mut seen_model_ids: HashSet<String> = HashSet::new();
|
||||||
for (provider_name, base_url, api_key, models) in provider_entries {
|
for (provider_name, provider_kind, provider_enabled, base_url, api_key, models) in
|
||||||
|
provider_entries
|
||||||
|
{
|
||||||
|
if !provider_enabled {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
for model in &models {
|
for model in &models {
|
||||||
|
if !model.enabled {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if !seen_model_ids.insert(model.model_id.clone()) {
|
if !seen_model_ids.insert(model.model_id.clone()) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register the routing entry
|
let reasoning_efforts: Vec<Option<&String>> =
|
||||||
let client_config = OpenAIClientConfig {
|
if provider_kind == OpenAIProviderKind::ChatGPTSubscription {
|
||||||
base_url: base_url.clone(),
|
// Keep the base model as the provider-default mode, then expose each
|
||||||
api_key: api_key.clone(),
|
// explicitly supported effort as a separate selectable variant.
|
||||||
model: None, // filled per-request from model_id
|
std::iter::once(None)
|
||||||
max_input_tokens: Some(openai_model_context_size(model)),
|
.chain(model.reasoning_efforts.iter().map(Some))
|
||||||
max_output_tokens: model.max_output_tokens,
|
.collect()
|
||||||
use_rig: model.use_rig,
|
} else {
|
||||||
supports_system_messages: model.supports_system_messages(),
|
vec![None]
|
||||||
};
|
};
|
||||||
self.openai_provider_routing
|
|
||||||
.insert(model.model_id.clone(), client_config);
|
|
||||||
|
|
||||||
let llm_info = LLMInfo {
|
for reasoning_effort in reasoning_efforts {
|
||||||
id: LLMId::from(model.model_id.as_str()),
|
let reasoning_effort = reasoning_effort.cloned();
|
||||||
display_name: model.display_name.clone(),
|
let model_key = reasoning_effort.as_deref().map_or_else(
|
||||||
base_model_name: model.display_name.clone(),
|
|| model.model_id.clone(),
|
||||||
reasoning_level: None,
|
|effort| openai_model_variant_id(&model.model_id, effort),
|
||||||
usage_metadata: LLMUsageMetadata {
|
);
|
||||||
request_multiplier: 1,
|
|
||||||
credit_multiplier: None,
|
// Register the routing entry. Reasoning variants keep the provider's
|
||||||
},
|
// actual model ID while using their synthetic key only for selection.
|
||||||
description: Some(provider_name.clone()),
|
let client_config = OpenAIClientConfig {
|
||||||
disable_reason: None,
|
kind: provider_kind,
|
||||||
vision_supported: model.vision_supported,
|
base_url: base_url.clone(),
|
||||||
spec: None,
|
api_key: api_key.clone(),
|
||||||
provider: LLMProvider::LiteLLM,
|
model: Some(model.model_id.clone()),
|
||||||
host_configs: HashMap::from([(
|
reasoning_effort: reasoning_effort.clone(),
|
||||||
LLMModelHost::DirectApi,
|
max_input_tokens: Some(openai_model_context_size(model)),
|
||||||
RoutingHostConfig {
|
max_output_tokens: model.max_output_tokens,
|
||||||
enabled: true,
|
use_rig: model.use_rig
|
||||||
model_routing_host: LLMModelHost::DirectApi,
|
|| provider_kind == OpenAIProviderKind::ChatGPTSubscription,
|
||||||
|
supports_system_messages: model.supports_system_messages(),
|
||||||
|
};
|
||||||
|
self.openai_provider_routing
|
||||||
|
.insert(model_key.clone(), client_config);
|
||||||
|
|
||||||
|
let display_name = reasoning_effort.as_deref().map_or_else(
|
||||||
|
|| model.display_name.clone(),
|
||||||
|
|effort| format!("{} ({effort})", model.display_name),
|
||||||
|
);
|
||||||
|
let llm_info = LLMInfo {
|
||||||
|
id: LLMId::from(model_key.as_str()),
|
||||||
|
display_name,
|
||||||
|
base_model_name: model.display_name.clone(),
|
||||||
|
reasoning_level: reasoning_effort,
|
||||||
|
usage_metadata: LLMUsageMetadata {
|
||||||
|
request_multiplier: 1,
|
||||||
|
credit_multiplier: None,
|
||||||
},
|
},
|
||||||
)]),
|
description: Some(provider_name.clone()),
|
||||||
discount_percentage: None,
|
disable_reason: None,
|
||||||
context_window: openai_model_context_window(model),
|
vision_supported: model.vision_supported,
|
||||||
};
|
spec: None,
|
||||||
self.models_by_feature
|
provider: LLMProvider::LiteLLM,
|
||||||
.agent_mode
|
host_configs: HashMap::from([(
|
||||||
.choices
|
LLMModelHost::DirectApi,
|
||||||
.push(llm_info.clone());
|
RoutingHostConfig {
|
||||||
self.models_by_feature.coding.choices.push(llm_info.clone());
|
enabled: true,
|
||||||
if let Some(ref mut cli) = self.models_by_feature.cli_agent {
|
model_routing_host: LLMModelHost::DirectApi,
|
||||||
cli.choices.push(llm_info);
|
},
|
||||||
|
)]),
|
||||||
|
discount_percentage: None,
|
||||||
|
context_window: openai_model_context_window(model),
|
||||||
|
};
|
||||||
|
self.models_by_feature
|
||||||
|
.agent_mode
|
||||||
|
.choices
|
||||||
|
.push(llm_info.clone());
|
||||||
|
self.models_by_feature.coding.choices.push(llm_info.clone());
|
||||||
|
if let Some(ref mut cli) = self.models_by_feature.cli_agent {
|
||||||
|
cli.choices.push(llm_info);
|
||||||
|
}
|
||||||
|
total_injected += 1;
|
||||||
}
|
}
|
||||||
total_injected += 1;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1438,6 +1549,43 @@ impl LLMPreferences {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Discovers models for a provider draft without persisting or injecting it.
|
||||||
|
///
|
||||||
|
/// The provider setup modal uses this to keep configuration changes atomic
|
||||||
|
/// until the user clicks Save.
|
||||||
|
#[cfg(not(target_family = "wasm"))]
|
||||||
|
pub(crate) async fn discover_openai_provider_models(
|
||||||
|
provider: OpenAIProviderConfig,
|
||||||
|
) -> Result<Vec<OpenAIModelConfig>, String> {
|
||||||
|
if provider.base_url.trim().is_empty() {
|
||||||
|
return Err("Enter a provider URL before testing the connection.".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let base_url = provider.base_url.trim_end_matches('/').to_string();
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(10))
|
||||||
|
.build()
|
||||||
|
.map_err(|error| format!("Could not create the provider client: {error}"))?;
|
||||||
|
let api_key = provider.api_key.as_deref().filter(|key| !key.is_empty());
|
||||||
|
|
||||||
|
let models = if let Some(models) =
|
||||||
|
fetch_from_litellm_model_info(&base_url, api_key, &client).await
|
||||||
|
{
|
||||||
|
models
|
||||||
|
} else {
|
||||||
|
fetch_from_openai_models(&base_url, api_key, &client).await
|
||||||
|
};
|
||||||
|
|
||||||
|
if models.is_empty() {
|
||||||
|
return Err(
|
||||||
|
"The provider responded, but no models were found at /model/info or /models."
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(models)
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns the `LLMInfo` for the base LLM to be used for an Agent Mode request.
|
/// Returns the `LLMInfo` for the base LLM to be used for an Agent Mode request.
|
||||||
pub fn get_active_base_model<'a>(
|
pub fn get_active_base_model<'a>(
|
||||||
&'a self,
|
&'a self,
|
||||||
@@ -2228,7 +2376,7 @@ fn openai_model_context_size(model: &OpenAIModelConfig) -> u32 {
|
|||||||
/// Merges endpoint metadata into a provider's configured models without
|
/// Merges endpoint metadata into a provider's configured models without
|
||||||
/// discarding local routing choices or manually configured models.
|
/// discarding local routing choices or manually configured models.
|
||||||
#[cfg(not(target_family = "wasm"))]
|
#[cfg(not(target_family = "wasm"))]
|
||||||
fn merge_discovered_provider_models(
|
pub(crate) fn merge_discovered_provider_models(
|
||||||
existing_models: &[OpenAIModelConfig],
|
existing_models: &[OpenAIModelConfig],
|
||||||
discovered_models: Vec<OpenAIModelConfig>,
|
discovered_models: Vec<OpenAIModelConfig>,
|
||||||
) -> Vec<OpenAIModelConfig> {
|
) -> Vec<OpenAIModelConfig> {
|
||||||
@@ -2245,6 +2393,7 @@ fn merge_discovered_provider_models(
|
|||||||
.find(|model| model.model_id == discovered.model_id)
|
.find(|model| model.model_id == discovered.model_id)
|
||||||
{
|
{
|
||||||
discovered.display_name = existing.display_name.clone();
|
discovered.display_name = existing.display_name.clone();
|
||||||
|
discovered.enabled = existing.enabled;
|
||||||
discovered.use_rig = existing.use_rig;
|
discovered.use_rig = existing.use_rig;
|
||||||
if existing.supports_system_messages.is_some() {
|
if existing.supports_system_messages.is_some() {
|
||||||
discovered.supports_system_messages = existing.supports_system_messages;
|
discovered.supports_system_messages = existing.supports_system_messages;
|
||||||
@@ -2271,6 +2420,11 @@ fn merge_discovered_provider_models(
|
|||||||
merged
|
merged
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_family = "wasm"))]
|
||||||
|
fn openai_model_variant_id(model_id: &str, reasoning_effort: &str) -> String {
|
||||||
|
format!("{model_id}::reasoning::{reasoning_effort}")
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(not(target_family = "wasm"))]
|
#[cfg(not(target_family = "wasm"))]
|
||||||
fn openai_model_context_window(model: &OpenAIModelConfig) -> LLMContextWindow {
|
fn openai_model_context_window(model: &OpenAIModelConfig) -> LLMContextWindow {
|
||||||
let context_size = openai_model_context_size(model);
|
let context_size = openai_model_context_size(model);
|
||||||
@@ -2400,6 +2554,8 @@ async fn fetch_from_litellm_model_info(
|
|||||||
} else {
|
} else {
|
||||||
model_info["supports_system_messages"].as_bool()
|
model_info["supports_system_messages"].as_bool()
|
||||||
},
|
},
|
||||||
|
reasoning_efforts: Vec::new(),
|
||||||
|
enabled: true,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -2527,6 +2683,8 @@ async fn fetch_from_openai_models(
|
|||||||
} else {
|
} else {
|
||||||
m["supports_system_messages"].as_bool()
|
m["supports_system_messages"].as_bool()
|
||||||
},
|
},
|
||||||
|
reasoning_efforts: Vec::new(),
|
||||||
|
enabled: true,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|||||||
@@ -152,6 +152,8 @@ fn openai_model(model_id: &str) -> OpenAIModelConfig {
|
|||||||
provider: None,
|
provider: None,
|
||||||
use_rig: false,
|
use_rig: false,
|
||||||
supports_system_messages: None,
|
supports_system_messages: None,
|
||||||
|
reasoning_efforts: Vec::new(),
|
||||||
|
enabled: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -556,6 +558,60 @@ fn disabled_providers_do_not_leave_models_in_the_runtime_inventory() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chatgpt_reasoning_modes_route_to_the_base_model_with_effort_metadata() {
|
||||||
|
App::test((), |mut app| async move {
|
||||||
|
initialize_settings_for_tests(&mut app);
|
||||||
|
AISettings::handle(&app).update(&mut app, |settings, ctx| {
|
||||||
|
settings
|
||||||
|
.bedrock_enabled
|
||||||
|
.set_value(false, ctx)
|
||||||
|
.expect("Bedrock setting should update");
|
||||||
|
settings
|
||||||
|
.acp_enabled
|
||||||
|
.set_value(false, ctx)
|
||||||
|
.expect("ACP setting should update");
|
||||||
|
settings
|
||||||
|
.openai_enabled
|
||||||
|
.set_value(true, ctx)
|
||||||
|
.expect("OpenAI setting should update");
|
||||||
|
settings
|
||||||
|
.openai_models
|
||||||
|
.set_value(Vec::new(), ctx)
|
||||||
|
.expect("OpenAI model setting should update");
|
||||||
|
settings
|
||||||
|
.openai_providers
|
||||||
|
.set_value(vec![crate::settings::ai::default_chatgpt_provider()], ctx)
|
||||||
|
.expect("OpenAI provider setting should update");
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut preferences = empty_preferences();
|
||||||
|
app.read(|ctx| preferences.inject_openai_models(ctx));
|
||||||
|
|
||||||
|
let mode_id = "gpt-5.4::reasoning::high";
|
||||||
|
let mode = preferences
|
||||||
|
.models_by_feature
|
||||||
|
.agent_mode
|
||||||
|
.choices
|
||||||
|
.iter()
|
||||||
|
.find(|model| model.id.as_str() == mode_id)
|
||||||
|
.expect("GPT-5.4 high mode should be available");
|
||||||
|
assert_eq!(mode.reasoning_level.as_deref(), Some("high"));
|
||||||
|
let routing = preferences
|
||||||
|
.openai_client_config_for_model(mode_id)
|
||||||
|
.expect("reasoning mode should have a routing entry");
|
||||||
|
assert_eq!(routing.model.as_deref(), Some("gpt-5.4"));
|
||||||
|
assert_eq!(routing.reasoning_effort.as_deref(), Some("high"));
|
||||||
|
|
||||||
|
let ultra_id = "gpt-5.6-sol::reasoning::ultra";
|
||||||
|
let ultra_routing = preferences
|
||||||
|
.openai_client_config_for_model(ultra_id)
|
||||||
|
.expect("GPT-5.6 Sol ultra mode should have a routing entry");
|
||||||
|
assert_eq!(ultra_routing.model.as_deref(), Some("gpt-5.6-sol"));
|
||||||
|
assert_eq!(ultra_routing.reasoning_effort.as_deref(), Some("ultra"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn provider_discovery_enables_rig_for_new_models_and_keeps_manual_models() {
|
fn provider_discovery_enables_rig_for_new_models_and_keeps_manual_models() {
|
||||||
let manual = openai_model("manual-model");
|
let manual = openai_model("manual-model");
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ use crate::cloud_object::{
|
|||||||
CloudObjectUuidLookup as _, GenericStringObjectFormat, JsonObjectType, Space,
|
CloudObjectUuidLookup as _, GenericStringObjectFormat, JsonObjectType, Space,
|
||||||
};
|
};
|
||||||
use crate::drive::CloudObjectTypeAndId;
|
use crate::drive::CloudObjectTypeAndId;
|
||||||
|
use crate::local_object_repository::{local_owner, LocalObjectRepository};
|
||||||
use crate::persistence::{
|
use crate::persistence::{
|
||||||
database_file_path_for_scope, establish_ro_connection, ModelEvent, PersistenceScope,
|
database_file_path_for_scope, establish_ro_connection, ModelEvent, PersistenceScope,
|
||||||
};
|
};
|
||||||
@@ -499,6 +500,19 @@ impl TemplatableMCPServerManager {
|
|||||||
initiated_by: InitiatedBy,
|
initiated_by: InitiatedBy,
|
||||||
ctx: &mut ModelContext<Self>,
|
ctx: &mut ModelContext<Self>,
|
||||||
) {
|
) {
|
||||||
|
if matches!(space, Space::Personal) {
|
||||||
|
let client_id = ClientId::default();
|
||||||
|
LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
|
||||||
|
repository.create_templatable_mcp_server_with_id(
|
||||||
|
SyncId::ClientId(client_id),
|
||||||
|
templatable_mcp_server,
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
self.fetch_cloud_servers(ctx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let owner = UserWorkspaces::as_ref(ctx).space_to_owner(space, ctx);
|
let owner = UserWorkspaces::as_ref(ctx).space_to_owner(space, ctx);
|
||||||
if let Some(owner) = owner {
|
if let Some(owner) = owner {
|
||||||
let update_manager = UpdateManager::handle(ctx);
|
let update_manager = UpdateManager::handle(ctx);
|
||||||
@@ -527,9 +541,24 @@ impl TemplatableMCPServerManager {
|
|||||||
template_server: TemplatableMCPServer,
|
template_server: TemplatableMCPServer,
|
||||||
ctx: &mut ModelContext<Self>,
|
ctx: &mut ModelContext<Self>,
|
||||||
) {
|
) {
|
||||||
let cloud_templatable_mcp_server =
|
let cloud_templatable_mcp_server = self
|
||||||
self.get_cloud_templatable_mcp_server(template_server.uuid);
|
.get_cloud_templatable_mcp_server(template_server.uuid)
|
||||||
|
.cloned();
|
||||||
if let Some(cloud_templatable_mcp_server) = cloud_templatable_mcp_server {
|
if let Some(cloud_templatable_mcp_server) = cloud_templatable_mcp_server {
|
||||||
|
if cloud_templatable_mcp_server.permissions.owner == local_owner()
|
||||||
|
&& cloud_templatable_mcp_server.id.into_client().is_some()
|
||||||
|
{
|
||||||
|
LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
|
||||||
|
repository.update_templatable_mcp_server(
|
||||||
|
template_server.uuid,
|
||||||
|
template_server,
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
self.fetch_cloud_servers(ctx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let update_manager = UpdateManager::handle(ctx);
|
let update_manager = UpdateManager::handle(ctx);
|
||||||
update_manager.update(ctx, |update_manager, ctx| {
|
update_manager.update(ctx, |update_manager, ctx| {
|
||||||
update_manager.update_templatable_mcp_server(
|
update_manager.update_templatable_mcp_server(
|
||||||
@@ -553,6 +582,16 @@ impl TemplatableMCPServerManager {
|
|||||||
|
|
||||||
let cloud_templatable_mcp_server = self.get_cloud_templatable_mcp_server(uuid);
|
let cloud_templatable_mcp_server = self.get_cloud_templatable_mcp_server(uuid);
|
||||||
if let Some(cloud_templatable_mcp_server) = cloud_templatable_mcp_server {
|
if let Some(cloud_templatable_mcp_server) = cloud_templatable_mcp_server {
|
||||||
|
if cloud_templatable_mcp_server.permissions.owner == local_owner()
|
||||||
|
&& cloud_templatable_mcp_server.id.into_client().is_some()
|
||||||
|
{
|
||||||
|
LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
|
||||||
|
repository.delete_templatable_mcp_server(uuid, ctx);
|
||||||
|
});
|
||||||
|
self.fetch_cloud_servers(ctx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let cloud_object_type_and_id = CloudObjectTypeAndId::GenericStringObject {
|
let cloud_object_type_and_id = CloudObjectTypeAndId::GenericStringObject {
|
||||||
object_type: GenericStringObjectFormat::Json(JsonObjectType::TemplatableMCPServer),
|
object_type: GenericStringObjectFormat::Json(JsonObjectType::TemplatableMCPServer),
|
||||||
id: cloud_templatable_mcp_server.id,
|
id: cloud_templatable_mcp_server.id,
|
||||||
@@ -1425,6 +1464,11 @@ impl TemplatableMCPServerManager {
|
|||||||
let cloud_templatable_mcp_server = self.get_cloud_templatable_mcp_server(template_uuid);
|
let cloud_templatable_mcp_server = self.get_cloud_templatable_mcp_server(template_uuid);
|
||||||
|
|
||||||
if let Some(cloud_templatable_mcp_server) = cloud_templatable_mcp_server {
|
if let Some(cloud_templatable_mcp_server) = cloud_templatable_mcp_server {
|
||||||
|
if cloud_templatable_mcp_server.permissions.owner == local_owner()
|
||||||
|
&& cloud_templatable_mcp_server.id.into_client().is_some()
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
let auth_state = AuthStateProvider::as_ref(ctx).get();
|
let auth_state = AuthStateProvider::as_ref(ctx).get();
|
||||||
let current_team = UserWorkspaces::as_ref(ctx).current_team();
|
let current_team = UserWorkspaces::as_ref(ctx).current_team();
|
||||||
|
|
||||||
@@ -1443,6 +1487,11 @@ impl TemplatableMCPServerManager {
|
|||||||
pub fn is_author(&self, template_uuid: Uuid, ctx: &AppContext) -> bool {
|
pub fn is_author(&self, template_uuid: Uuid, ctx: &AppContext) -> bool {
|
||||||
let cloud_templatable_mcp_server = self.get_cloud_templatable_mcp_server(template_uuid);
|
let cloud_templatable_mcp_server = self.get_cloud_templatable_mcp_server(template_uuid);
|
||||||
if let Some(cloud_templatable_mcp_server) = cloud_templatable_mcp_server {
|
if let Some(cloud_templatable_mcp_server) = cloud_templatable_mcp_server {
|
||||||
|
if cloud_templatable_mcp_server.permissions.owner == local_owner()
|
||||||
|
&& cloud_templatable_mcp_server.id.into_client().is_some()
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
let auth_state = AuthStateProvider::as_ref(ctx).get();
|
let auth_state = AuthStateProvider::as_ref(ctx).get();
|
||||||
cloud_templatable_mcp_server.metadata().creator_uid
|
cloud_templatable_mcp_server.metadata().creator_uid
|
||||||
== auth_state.user_id().map(|user_id| user_id.as_string())
|
== auth_state.user_id().map(|user_id| user_id.as_string())
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ pub mod bedrock;
|
|||||||
pub(crate) mod bedrock_credentials;
|
pub(crate) mod bedrock_credentials;
|
||||||
pub(crate) mod block_context;
|
pub(crate) mod block_context;
|
||||||
pub(crate) mod blocklist;
|
pub(crate) mod blocklist;
|
||||||
|
#[cfg(not(target_family = "wasm"))]
|
||||||
|
pub(crate) mod chatgpt_auth;
|
||||||
#[cfg(any(feature = "local_fs", not(target_family = "wasm")))]
|
#[cfg(any(feature = "local_fs", not(target_family = "wasm")))]
|
||||||
pub(crate) mod codebase_auto_indexing;
|
pub(crate) mod codebase_auto_indexing;
|
||||||
pub mod control_code_parser;
|
pub mod control_code_parser;
|
||||||
@@ -80,6 +82,8 @@ pub(crate) use ai::paths;
|
|||||||
pub fn init(app: &mut AppContext) {
|
pub fn init(app: &mut AppContext) {
|
||||||
#[cfg(not(target_family = "wasm"))]
|
#[cfg(not(target_family = "wasm"))]
|
||||||
app.add_singleton_model(acp::AcpRuntimeModel::new);
|
app.add_singleton_model(acp::AcpRuntimeModel::new);
|
||||||
|
#[cfg(not(target_family = "wasm"))]
|
||||||
|
app.add_singleton_model(|_| chatgpt_auth::ChatGPTAuthModel::new());
|
||||||
blocklist::keyboard_navigable_buttons::init(app);
|
blocklist::keyboard_navigable_buttons::init(app);
|
||||||
blocklist::block::number_shortcut_buttons::init(app);
|
blocklist::block::number_shortcut_buttons::init(app);
|
||||||
blocklist::toggleable_items::init(app);
|
blocklist::toggleable_items::init(app);
|
||||||
|
|||||||
@@ -4,11 +4,15 @@ use bytes::Bytes;
|
|||||||
use futures::Stream;
|
use futures::Stream;
|
||||||
use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE};
|
use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE};
|
||||||
|
|
||||||
|
use crate::settings::OpenAIProviderKind;
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct OpenAIClientConfig {
|
pub struct OpenAIClientConfig {
|
||||||
|
pub kind: OpenAIProviderKind,
|
||||||
pub base_url: String,
|
pub base_url: String,
|
||||||
pub api_key: Option<String>,
|
pub api_key: Option<String>,
|
||||||
pub model: Option<String>,
|
pub model: Option<String>,
|
||||||
|
pub reasoning_effort: Option<String>,
|
||||||
pub max_input_tokens: Option<u32>,
|
pub max_input_tokens: Option<u32>,
|
||||||
pub max_output_tokens: Option<u32>,
|
pub max_output_tokens: Option<u32>,
|
||||||
pub use_rig: bool,
|
pub use_rig: bool,
|
||||||
|
|||||||
+40
-16
@@ -7,7 +7,10 @@ use galaxy_agent_core::{
|
|||||||
turn_control, AgentError, AgentEvent, AgentRuntime, MessageContent, MessageRole, ToolCall,
|
turn_control, AgentError, AgentEvent, AgentRuntime, MessageContent, MessageRole, ToolCall,
|
||||||
ToolCallDecision, ToolEvent, ToolPolicy, ToolResult, TurnCommand,
|
ToolCallDecision, ToolEvent, ToolPolicy, ToolResult, TurnCommand,
|
||||||
};
|
};
|
||||||
use galaxy_agent_rig::{OpenAICompatibleRuntime, OpenAICompatibleRuntimeConfig};
|
use galaxy_agent_rig::{
|
||||||
|
ChatGPTSubscriptionRuntime, ChatGPTSubscriptionRuntimeConfig, OpenAICompatibleRuntime,
|
||||||
|
OpenAICompatibleRuntimeConfig,
|
||||||
|
};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use warp_multi_agent_api::ToolType;
|
use warp_multi_agent_api::ToolType;
|
||||||
|
|
||||||
@@ -24,6 +27,7 @@ use crate::ai::openai::client::OpenAIClientConfig;
|
|||||||
use crate::ai::provider::types::{ContentPart, ConversationMessage};
|
use crate::ai::provider::types::{ContentPart, ConversationMessage};
|
||||||
use crate::ai::runtime::{RuntimeResponseConfig, RuntimeResponseTranslator};
|
use crate::ai::runtime::{RuntimeResponseConfig, RuntimeResponseTranslator};
|
||||||
use crate::server::server_api::AIApiError;
|
use crate::server::server_api::AIApiError;
|
||||||
|
use crate::settings::OpenAIProviderKind;
|
||||||
|
|
||||||
pub(crate) fn rig_openai_response_stream(
|
pub(crate) fn rig_openai_response_stream(
|
||||||
config: OpenAIClientConfig,
|
config: OpenAIClientConfig,
|
||||||
@@ -35,21 +39,41 @@ pub(crate) fn rig_openai_response_stream(
|
|||||||
let skill_path_origin = params.session_context.skill_path_origin();
|
let skill_path_origin = params.session_context.skill_path_origin();
|
||||||
let prepared = prepare_rig_turn(&config, params, supported_tools, supported_cli_agent_tools);
|
let prepared = prepare_rig_turn(&config, params, supported_tools, supported_cli_agent_tools);
|
||||||
let model_id = prepared.request.model.as_str().to_string();
|
let model_id = prepared.request.model.as_str().to_string();
|
||||||
let runtime = OpenAICompatibleRuntime::new(OpenAICompatibleRuntimeConfig {
|
match config.kind {
|
||||||
base_url: config.base_url,
|
OpenAIProviderKind::OpenAICompatible => {
|
||||||
api_key: config.api_key,
|
let runtime = OpenAICompatibleRuntime::new(OpenAICompatibleRuntimeConfig {
|
||||||
model: model_id.clone(),
|
base_url: config.base_url,
|
||||||
max_output_tokens: config.max_output_tokens.map(u64::from),
|
api_key: config.api_key,
|
||||||
supports_system_messages: config.supports_system_messages,
|
model: model_id.clone(),
|
||||||
});
|
max_output_tokens: config.max_output_tokens.map(u64::from),
|
||||||
rig_response_stream(
|
supports_system_messages: config.supports_system_messages,
|
||||||
runtime,
|
});
|
||||||
prepared,
|
rig_response_stream(
|
||||||
skill_path_origin,
|
runtime,
|
||||||
config.max_input_tokens,
|
prepared,
|
||||||
"rig_openai_compatible",
|
skill_path_origin,
|
||||||
cancellation_rx,
|
config.max_input_tokens,
|
||||||
)
|
"rig_openai_compatible",
|
||||||
|
cancellation_rx,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
OpenAIProviderKind::ChatGPTSubscription => {
|
||||||
|
let runtime = ChatGPTSubscriptionRuntime::new(ChatGPTSubscriptionRuntimeConfig {
|
||||||
|
model: model_id,
|
||||||
|
reasoning_effort: config.reasoning_effort,
|
||||||
|
max_output_tokens: config.max_output_tokens.map(u64::from),
|
||||||
|
auth_file: None,
|
||||||
|
});
|
||||||
|
rig_response_stream(
|
||||||
|
runtime,
|
||||||
|
prepared,
|
||||||
|
skill_path_origin,
|
||||||
|
config.max_input_tokens,
|
||||||
|
"rig_chatgpt_subscription",
|
||||||
|
cancellation_rx,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn rig_bedrock_response_stream(
|
pub(crate) async fn rig_bedrock_response_stream(
|
||||||
|
|||||||
@@ -113,7 +113,15 @@ fn prepare_rig_turn_for_provider(
|
|||||||
supported_tools
|
supported_tools
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let (tools, mcp_tool_aliases) = tool_definitions(&available_tools, mcp_context.as_ref());
|
let (mut tools, mcp_tool_aliases) = tool_definitions(&available_tools, mcp_context.as_ref());
|
||||||
|
if matches!(mode, RigRequestMode::Cli) {
|
||||||
|
// History recall cannot advance a running command and is handled inline by the Rig
|
||||||
|
// adapter (without producing a client action that can trigger another turn). Keeping it
|
||||||
|
// in the CLI tool list lets the model spend its entire monitor turn recalling the prior
|
||||||
|
// snapshot instead of scheduling `read_shell_command_output`, so make polling the only
|
||||||
|
// way to inspect the active command here.
|
||||||
|
tools.retain(|tool| tool.name != "recall_tool_history");
|
||||||
|
}
|
||||||
let system_prompt = build_system_prompt(&input, &tools, &global_rules, mode);
|
let system_prompt = build_system_prompt(&input, &tools, &global_rules, mode);
|
||||||
|
|
||||||
let mut new_messages = input_messages(input, tool_results);
|
let mut new_messages = input_messages(input, tool_results);
|
||||||
@@ -406,6 +414,16 @@ enum RigRequestMode {
|
|||||||
|
|
||||||
fn request_mode(inputs: &[AIAgentInput]) -> RigRequestMode {
|
fn request_mode(inputs: &[AIAgentInput]) -> RigRequestMode {
|
||||||
for input in inputs {
|
for input in inputs {
|
||||||
|
// A direct-provider follow-up carries an LRC snapshot as an action result rather than
|
||||||
|
// as a user query with `running_command`. Treat that result as a CLI-monitor turn so the
|
||||||
|
// request receives the dedicated polling instructions and CLI tool set. Without this,
|
||||||
|
// the model sees a generic tool-result turn and may stop after inspecting the snapshot
|
||||||
|
// (or call history recall) instead of scheduling the next output read.
|
||||||
|
if let AIAgentInput::ActionResult { result, .. } = input {
|
||||||
|
if result.result.triggers_server_subagent() {
|
||||||
|
return RigRequestMode::Cli;
|
||||||
|
}
|
||||||
|
}
|
||||||
if matches!(
|
if matches!(
|
||||||
input,
|
input,
|
||||||
AIAgentInput::UserQuery {
|
AIAgentInput::UserQuery {
|
||||||
@@ -719,7 +737,7 @@ fn build_system_prompt(
|
|||||||
"## Orchestration Mode\nDelegate only independent, bounded work where parallelism materially helps, then synthesize the results.\n\n",
|
"## Orchestration Mode\nDelegate only independent, bounded work where parallelism materially helps, then synthesize the results.\n\n",
|
||||||
),
|
),
|
||||||
RigRequestMode::Cli => prompt.push_str(
|
RigRequestMode::Cli => prompt.push_str(
|
||||||
"## Running Command Monitor\nMonitor the existing command by its command ID. Never start a duplicate command. Poll briefly, respect stop conditions, and report only verified outcomes.\n\n",
|
"## Running Command Monitor\nThis turn concerns a running or just-finished shell command. Act as its dedicated monitor while still following the user's steering messages. Use the command ID from the tool result for every read/write operation. If the result says the command finished, report its outcome and stop polling. Otherwise, poll with `read_shell_command_output` and use short delays. Never choose a poll interval that crosses a user-specified deadline or stop condition. When an explicit stop condition is met, call `interrupt_shell_command` immediately, then poll briefly to verify the outcome. Never start a duplicate command merely to check its state, and never report completion while a result says it is still running.\n\n",
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
prompt.push_str("## Available Tools\n");
|
prompt.push_str("## Available Tools\n");
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ use warp_multi_agent_api::ToolType;
|
|||||||
|
|
||||||
use super::{input_messages, prepare_bedrock_rig_turn, prepare_rig_turn, tool_definitions};
|
use super::{input_messages, prepare_bedrock_rig_turn, prepare_rig_turn, tool_definitions};
|
||||||
use crate::ai::agent::api::RequestParams;
|
use crate::ai::agent::api::RequestParams;
|
||||||
|
use crate::ai::agent::task::TaskId;
|
||||||
use crate::ai::agent::{
|
use crate::ai::agent::{
|
||||||
AIAgentContext, AIAgentInput, AnyFileContent, FileContext, MCPContext, MCPServer, UserQueryMode,
|
AIAgentActionId, AIAgentActionResult, AIAgentActionResultType, AIAgentContext, AIAgentInput,
|
||||||
|
AnyFileContent, FileContext, MCPContext, MCPServer, RequestCommandOutputResult, UserQueryMode,
|
||||||
};
|
};
|
||||||
use crate::ai::llms::LLMId;
|
use crate::ai::llms::LLMId;
|
||||||
use crate::ai::openai::client::OpenAIClientConfig;
|
use crate::ai::openai::client::OpenAIClientConfig;
|
||||||
@@ -18,9 +20,11 @@ use crate::ai::skills::SkillDescriptor;
|
|||||||
|
|
||||||
fn config() -> OpenAIClientConfig {
|
fn config() -> OpenAIClientConfig {
|
||||||
OpenAIClientConfig {
|
OpenAIClientConfig {
|
||||||
|
kind: crate::settings::OpenAIProviderKind::OpenAICompatible,
|
||||||
base_url: "http://localhost:4000/v1".to_string(),
|
base_url: "http://localhost:4000/v1".to_string(),
|
||||||
api_key: None,
|
api_key: None,
|
||||||
model: Some("provider-model".to_string()),
|
model: Some("provider-model".to_string()),
|
||||||
|
reasoning_effort: None,
|
||||||
max_input_tokens: Some(128_000),
|
max_input_tokens: Some(128_000),
|
||||||
max_output_tokens: Some(8_192),
|
max_output_tokens: Some(8_192),
|
||||||
use_rig: true,
|
use_rig: true,
|
||||||
@@ -131,6 +135,85 @@ fn rig_prompt_requires_follow_through_without_manual_continue_prompts() {
|
|||||||
assert!(prompt.contains("After each tool result, choose and perform the next necessary step"));
|
assert!(prompt.contains("After each tool result, choose and perform the next necessary step"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lrc_snapshot_follow_up_uses_the_cli_monitor_prompt_and_tools() {
|
||||||
|
let block_id: galaxy_terminal::model::BlockId = "precmd-lrc-test".to_string().into();
|
||||||
|
let result = AIAgentActionResult {
|
||||||
|
id: AIAgentActionId::from("run-call".to_owned()),
|
||||||
|
task_id: TaskId::new("task".to_owned()),
|
||||||
|
result: AIAgentActionResultType::RequestCommandOutput(
|
||||||
|
RequestCommandOutputResult::LongRunningCommandSnapshot {
|
||||||
|
block_id: block_id.clone(),
|
||||||
|
command: "bash loop.sh".to_string(),
|
||||||
|
grid_contents: "Running for 2 seconds...".to_string(),
|
||||||
|
cursor: String::new(),
|
||||||
|
is_alt_screen_active: false,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
};
|
||||||
|
let snapshot_tool_result = ToolResult {
|
||||||
|
call_id: "run-call".to_string(),
|
||||||
|
content: result.result.model_content(),
|
||||||
|
status: ToolResultStatus::Success,
|
||||||
|
};
|
||||||
|
let mut params = RequestParams::new_for_test();
|
||||||
|
params.message_history = vec![galaxy_agent_core::ConversationMessage {
|
||||||
|
role: MessageRole::Assistant,
|
||||||
|
content: MessageContent::ToolUse {
|
||||||
|
tool_use_id: "run-call".to_string(),
|
||||||
|
name: "run_shell_command".to_string(),
|
||||||
|
input: serde_json::json!({
|
||||||
|
"command": "bash loop.sh",
|
||||||
|
"wait_until_complete": false,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}];
|
||||||
|
params.input = vec![AIAgentInput::ActionResult {
|
||||||
|
result,
|
||||||
|
context: Arc::from([]),
|
||||||
|
}];
|
||||||
|
params.tool_results = vec![snapshot_tool_result];
|
||||||
|
|
||||||
|
let prepared = prepare_rig_turn(
|
||||||
|
&config(),
|
||||||
|
params,
|
||||||
|
vec![ToolType::RunShellCommand],
|
||||||
|
vec![ToolType::ReadShellCommandOutput],
|
||||||
|
);
|
||||||
|
let prompt = prepared.request.system_prompt.expect("system prompt");
|
||||||
|
|
||||||
|
assert!(prompt.contains("## Running Command Monitor"));
|
||||||
|
assert!(prompt.contains("poll with `read_shell_command_output`"));
|
||||||
|
assert!(prepared
|
||||||
|
.request
|
||||||
|
.tools
|
||||||
|
.iter()
|
||||||
|
.any(|tool| tool.name == "read_shell_command_output"));
|
||||||
|
assert!(!prepared
|
||||||
|
.request
|
||||||
|
.tools
|
||||||
|
.iter()
|
||||||
|
.any(|tool| tool.name == "recall_tool_history"));
|
||||||
|
assert!(prepared
|
||||||
|
.request
|
||||||
|
.messages
|
||||||
|
.iter()
|
||||||
|
.any(|message| match &message.content {
|
||||||
|
MessageContent::ToolResult { content, .. } => {
|
||||||
|
content.contains("Command ID: precmd-lrc-test")
|
||||||
|
&& content.contains("Continue monitoring with `read_shell_command_output`")
|
||||||
|
}
|
||||||
|
MessageContent::MultiPart(parts) => parts.iter().any(|part| {
|
||||||
|
matches!(
|
||||||
|
part,
|
||||||
|
ContentPart::ToolResult { content, .. }
|
||||||
|
if content.contains("Command ID: precmd-lrc-test")
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
_ => false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rig_prompt_requires_matching_project_skills_to_be_read_before_action() {
|
fn rig_prompt_requires_matching_project_skills_to_be_read_before_action() {
|
||||||
let skill_path = LocalOrRemotePath::Local(PathBuf::from(
|
let skill_path = LocalOrRemotePath::Local(PathBuf::from(
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ use crate::ai::execution_profiles::{
|
|||||||
AIExecutionProfile, CloudAIExecutionProfile, CloudAIExecutionProfileModel,
|
AIExecutionProfile, CloudAIExecutionProfile, CloudAIExecutionProfileModel,
|
||||||
};
|
};
|
||||||
use crate::ai::facts::{AIFact, CloudAIFact, CloudAIFactModel};
|
use crate::ai::facts::{AIFact, CloudAIFact, CloudAIFactModel};
|
||||||
|
use crate::ai::mcp::templatable::{
|
||||||
|
CloudTemplatableMCPServer, CloudTemplatableMCPServerModel, TemplatableMCPServer,
|
||||||
|
};
|
||||||
use crate::auth::UserUid;
|
use crate::auth::UserUid;
|
||||||
use crate::cloud_object::model::generic_string_model::GenericStringObjectId;
|
use crate::cloud_object::model::generic_string_model::GenericStringObjectId;
|
||||||
use crate::cloud_object::model::persistence::{CloudModel, CloudModelEvent};
|
use crate::cloud_object::model::persistence::{CloudModel, CloudModelEvent};
|
||||||
@@ -413,6 +416,60 @@ impl LocalObjectRepository {
|
|||||||
Some(duplicate_id)
|
Some(duplicate_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn templatable_mcp_server(
|
||||||
|
&self,
|
||||||
|
uuid: uuid::Uuid,
|
||||||
|
app: &AppContext,
|
||||||
|
) -> Option<CloudTemplatableMCPServer> {
|
||||||
|
CloudModel::as_ref(app)
|
||||||
|
.get_all_objects_of_type::<GenericStringObjectId, CloudTemplatableMCPServerModel>()
|
||||||
|
.find(|server| server.model().string_model.uuid == uuid)
|
||||||
|
.cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_templatable_mcp_server_with_id(
|
||||||
|
&mut self,
|
||||||
|
id: SyncId,
|
||||||
|
server: TemplatableMCPServer,
|
||||||
|
ctx: &mut ModelContext<Self>,
|
||||||
|
) {
|
||||||
|
self.upsert_templatable_mcp_server(
|
||||||
|
GenericCloudObject::new(
|
||||||
|
id,
|
||||||
|
CloudTemplatableMCPServerModel::new(server),
|
||||||
|
locally_saved_metadata(None),
|
||||||
|
local_permissions(),
|
||||||
|
),
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_templatable_mcp_server(
|
||||||
|
&mut self,
|
||||||
|
uuid: uuid::Uuid,
|
||||||
|
server: TemplatableMCPServer,
|
||||||
|
ctx: &mut ModelContext<Self>,
|
||||||
|
) -> bool {
|
||||||
|
let Some(mut object) = self.templatable_mcp_server(uuid, ctx) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
object.set_model(CloudTemplatableMCPServerModel::new(server));
|
||||||
|
set_locally_saved_metadata(&mut object.metadata);
|
||||||
|
self.upsert_templatable_mcp_server(object, ctx);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_templatable_mcp_server(
|
||||||
|
&mut self,
|
||||||
|
uuid: uuid::Uuid,
|
||||||
|
ctx: &mut ModelContext<Self>,
|
||||||
|
) -> bool {
|
||||||
|
let Some(object) = self.templatable_mcp_server(uuid, ctx) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
self.delete_local_object(object.id, ObjectIdType::GenericStringObject, ctx)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn create_workflow_with_id(
|
pub fn create_workflow_with_id(
|
||||||
&mut self,
|
&mut self,
|
||||||
id: SyncId,
|
id: SyncId,
|
||||||
@@ -514,6 +571,19 @@ impl LocalObjectRepository {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn upsert_templatable_mcp_server(
|
||||||
|
&self,
|
||||||
|
object: CloudTemplatableMCPServer,
|
||||||
|
ctx: &mut ModelContext<Self>,
|
||||||
|
) {
|
||||||
|
CloudModel::handle(ctx).update(ctx, |cloud_model, ctx| {
|
||||||
|
cloud_model.upsert_local_object(object.clone(), ctx);
|
||||||
|
});
|
||||||
|
self.save(ModelEvent::UpsertGenericStringObject {
|
||||||
|
object: Box::new(object),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
fn upsert_notebook(&self, notebook: CloudNotebook, ctx: &mut ModelContext<Self>) {
|
fn upsert_notebook(&self, notebook: CloudNotebook, ctx: &mut ModelContext<Self>) {
|
||||||
CloudModel::handle(ctx).update(ctx, |cloud_model, ctx| {
|
CloudModel::handle(ctx).update(ctx, |cloud_model, ctx| {
|
||||||
cloud_model.upsert_local_object(notebook.clone(), ctx);
|
cloud_model.upsert_local_object(notebook.clone(), ctx);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ use galaxyui::App;
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::ai::execution_profiles::{AIExecutionProfile, ActionPermission};
|
use crate::ai::execution_profiles::{AIExecutionProfile, ActionPermission};
|
||||||
use crate::ai::facts::AIMemory;
|
use crate::ai::facts::AIMemory;
|
||||||
|
use crate::ai::mcp::templatable::TemplatableMCPServer;
|
||||||
use crate::cloud_object::model::generic_string_model::CloudStringObject;
|
use crate::cloud_object::model::generic_string_model::CloudStringObject;
|
||||||
use crate::env_vars::{EnvVar, EnvVarCollection, EnvVarValue};
|
use crate::env_vars::{EnvVar, EnvVarCollection, EnvVarValue};
|
||||||
use crate::notebooks::CloudNotebookModel;
|
use crate::notebooks::CloudNotebookModel;
|
||||||
@@ -281,6 +282,63 @@ fn create_update_duplicate_trash_and_delete_env_var_collection_are_local() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_update_and_delete_mcp_config_are_local_and_persisted() {
|
||||||
|
App::test((), |mut app| async move {
|
||||||
|
let receiver = initialize_app(&mut app);
|
||||||
|
let repository = LocalObjectRepository::handle(&app);
|
||||||
|
let id = SyncId::ClientId(ClientId::new());
|
||||||
|
let uuid = uuid::Uuid::new_v4();
|
||||||
|
let server = TemplatableMCPServer {
|
||||||
|
uuid,
|
||||||
|
name: "Local MCP".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
repository.update(&mut app, |repository, ctx| {
|
||||||
|
repository.create_templatable_mcp_server_with_id(id, server, ctx);
|
||||||
|
});
|
||||||
|
assert!(matches!(
|
||||||
|
receiver.recv().unwrap(),
|
||||||
|
ModelEvent::UpsertGenericStringObject { .. }
|
||||||
|
));
|
||||||
|
repository.read(&app, |repository, app| {
|
||||||
|
let server = repository
|
||||||
|
.templatable_mcp_server(uuid, app)
|
||||||
|
.expect("created MCP config");
|
||||||
|
assert_eq!(server.id, id);
|
||||||
|
assert_eq!(server.model().string_model.name, "Local MCP");
|
||||||
|
});
|
||||||
|
|
||||||
|
let updated = repository.update(&mut app, |repository, ctx| {
|
||||||
|
repository.update_templatable_mcp_server(
|
||||||
|
uuid,
|
||||||
|
TemplatableMCPServer {
|
||||||
|
uuid,
|
||||||
|
name: "Updated MCP".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
assert!(updated);
|
||||||
|
assert!(matches!(
|
||||||
|
receiver.recv().unwrap(),
|
||||||
|
ModelEvent::UpsertGenericStringObject { .. }
|
||||||
|
));
|
||||||
|
|
||||||
|
let deleted = repository.update(&mut app, |repository, ctx| {
|
||||||
|
repository.delete_templatable_mcp_server(uuid, ctx)
|
||||||
|
});
|
||||||
|
assert!(deleted);
|
||||||
|
assert!(matches!(
|
||||||
|
receiver.recv().unwrap(),
|
||||||
|
ModelEvent::DeleteObjects { ids }
|
||||||
|
if ids == vec![(id, ObjectIdType::GenericStringObject)]
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn create_update_and_delete_rule_are_local_and_persisted() {
|
fn create_update_and_delete_rule_are_local_and_persisted() {
|
||||||
App::test((), |mut app| async move {
|
App::test((), |mut app| async move {
|
||||||
|
|||||||
+140
-15
@@ -841,6 +841,10 @@ fn default_context_size() -> u32 {
|
|||||||
200_000
|
200_000
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_enabled() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
/// Configuration for a single OpenAI-compatible (LiteLLM) model.
|
/// Configuration for a single OpenAI-compatible (LiteLLM) model.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, schemars::JsonSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, schemars::JsonSchema)]
|
||||||
#[schemars(description = "Configuration for a single OpenAI-compatible model (e.g. via LiteLLM).")]
|
#[schemars(description = "Configuration for a single OpenAI-compatible model (e.g. via LiteLLM).")]
|
||||||
@@ -889,6 +893,14 @@ pub struct OpenAIModelConfig {
|
|||||||
description = "Whether this endpoint accepts system-role messages. Set false for ChatGPT-backed LiteLLM models that reject them."
|
description = "Whether this endpoint accepts system-role messages. Set false for ChatGPT-backed LiteLLM models that reject them."
|
||||||
)]
|
)]
|
||||||
pub supports_system_messages: Option<bool>,
|
pub supports_system_messages: Option<bool>,
|
||||||
|
#[serde(default)]
|
||||||
|
#[schemars(
|
||||||
|
description = "Reasoning effort modes supported by this model when using the ChatGPT subscription provider."
|
||||||
|
)]
|
||||||
|
pub reasoning_efforts: Vec<String>,
|
||||||
|
#[serde(default = "default_enabled")]
|
||||||
|
#[schemars(description = "Whether this model is enabled for the model picker.")]
|
||||||
|
pub enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl settings_value::SettingsValue for OpenAIModelConfig {}
|
impl settings_value::SettingsValue for OpenAIModelConfig {}
|
||||||
@@ -902,6 +914,20 @@ impl OpenAIModelConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The protocol and authentication used by an OpenAI model provider.
|
||||||
|
#[derive(
|
||||||
|
Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, schemars::JsonSchema,
|
||||||
|
)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum OpenAIProviderKind {
|
||||||
|
/// A regular OpenAI-compatible `/chat/completions` endpoint.
|
||||||
|
#[serde(alias = "openai")]
|
||||||
|
#[default]
|
||||||
|
OpenAICompatible,
|
||||||
|
/// The ChatGPT subscription backend, authenticated with ChatGPT OAuth.
|
||||||
|
ChatGPTSubscription,
|
||||||
|
}
|
||||||
|
|
||||||
/// Configuration for a single OpenAI-compatible provider endpoint.
|
/// Configuration for a single OpenAI-compatible provider endpoint.
|
||||||
///
|
///
|
||||||
/// Multiple providers can be configured simultaneously (e.g. LiteLLM for cloud models,
|
/// Multiple providers can be configured simultaneously (e.g. LiteLLM for cloud models,
|
||||||
@@ -911,6 +937,12 @@ impl OpenAIModelConfig {
|
|||||||
description = "Configuration for an OpenAI-compatible provider endpoint (e.g. LiteLLM, Ollama, vLLM)."
|
description = "Configuration for an OpenAI-compatible provider endpoint (e.g. LiteLLM, Ollama, vLLM)."
|
||||||
)]
|
)]
|
||||||
pub struct OpenAIProviderConfig {
|
pub struct OpenAIProviderConfig {
|
||||||
|
#[serde(default)]
|
||||||
|
#[schemars(description = "Provider protocol and authentication kind.")]
|
||||||
|
pub kind: OpenAIProviderKind,
|
||||||
|
#[serde(default = "default_enabled")]
|
||||||
|
#[schemars(description = "Whether this provider is enabled for AI requests.")]
|
||||||
|
pub enabled: bool,
|
||||||
#[schemars(description = "Display name for this provider (shown in model picker).")]
|
#[schemars(description = "Display name for this provider (shown in model picker).")]
|
||||||
pub name: String,
|
pub name: String,
|
||||||
#[schemars(description = "Base URL for the OpenAI-compatible API endpoint.")]
|
#[schemars(description = "Base URL for the OpenAI-compatible API endpoint.")]
|
||||||
@@ -928,25 +960,97 @@ 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_openai_providers() -> Vec<OpenAIProviderConfig> {
|
fn default_chatgpt_models() -> Vec<OpenAIModelConfig> {
|
||||||
vec![OpenAIProviderConfig {
|
// The ChatGPT OAuth backend does not expose a model-listing capability through Rig,
|
||||||
name: "LiteLLM (ai.ryserve.net)".to_string(),
|
// so keep this catalog small and explicit. Reasoning variants are expanded into
|
||||||
base_url: INITIAL_LITELLM_BASE_URL.to_string(),
|
// selectable LLM entries when the provider is injected into the runtime inventory.
|
||||||
// Credentials are deliberately never committed. Set this locally in
|
[
|
||||||
// ~/.galaxy/settings.toml before sending a request.
|
(
|
||||||
api_key: None,
|
"gpt-5.6-sol",
|
||||||
models: vec![OpenAIModelConfig {
|
"GPT-5.6 Sol",
|
||||||
model_id: INITIAL_RIG_MODEL_ID.to_string(),
|
vec!["low", "medium", "high", "xhigh", "max", "ultra"],
|
||||||
display_name: "Codex GPT-5.6 SOL (xhigh)".to_string(),
|
),
|
||||||
|
(
|
||||||
|
"gpt-5.6-terra",
|
||||||
|
"GPT-5.6 Terra",
|
||||||
|
vec!["low", "medium", "high", "xhigh", "max", "ultra"],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"gpt-5.6-luna",
|
||||||
|
"GPT-5.6 Luna",
|
||||||
|
vec!["low", "medium", "high", "xhigh", "max", "ultra"],
|
||||||
|
),
|
||||||
|
("gpt-5.4", "GPT-5.4", vec!["low", "medium", "high", "xhigh"]),
|
||||||
|
(
|
||||||
|
"gpt-5.4-pro",
|
||||||
|
"GPT-5.4 Pro",
|
||||||
|
vec!["medium", "high", "xhigh"],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"gpt-5.3-codex",
|
||||||
|
"GPT-5.3 Codex",
|
||||||
|
vec!["low", "medium", "high", "xhigh"],
|
||||||
|
),
|
||||||
|
("gpt-5.3-codex-spark", "GPT-5.3 Codex Spark", vec![]),
|
||||||
|
("gpt-5.3-instant", "GPT-5.3 Instant", vec![]),
|
||||||
|
("gpt-5.3-chat-latest", "GPT-5.3 Chat Latest", vec![]),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.map(
|
||||||
|
|(model_id, display_name, reasoning_efforts)| OpenAIModelConfig {
|
||||||
|
model_id: model_id.to_string(),
|
||||||
|
display_name: display_name.to_string(),
|
||||||
vision_supported: false,
|
vision_supported: false,
|
||||||
context_size: default_context_size(),
|
context_size: default_context_size(),
|
||||||
max_input_tokens: None,
|
max_input_tokens: None,
|
||||||
max_output_tokens: None,
|
max_output_tokens: None,
|
||||||
provider: Some("openai".to_string()),
|
provider: Some("openai".to_string()),
|
||||||
use_rig: true,
|
use_rig: true,
|
||||||
supports_system_messages: Some(false),
|
supports_system_messages: Some(true),
|
||||||
}],
|
reasoning_efforts: reasoning_efforts.into_iter().map(str::to_string).collect(),
|
||||||
}]
|
enabled: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn default_chatgpt_provider() -> OpenAIProviderConfig {
|
||||||
|
OpenAIProviderConfig {
|
||||||
|
kind: OpenAIProviderKind::ChatGPTSubscription,
|
||||||
|
enabled: true,
|
||||||
|
name: "ChatGPT Subscription".to_string(),
|
||||||
|
base_url: String::new(),
|
||||||
|
api_key: None,
|
||||||
|
models: default_chatgpt_models(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_openai_providers() -> Vec<OpenAIProviderConfig> {
|
||||||
|
vec![
|
||||||
|
OpenAIProviderConfig {
|
||||||
|
kind: OpenAIProviderKind::OpenAICompatible,
|
||||||
|
enabled: true,
|
||||||
|
name: "LiteLLM (ai.ryserve.net)".to_string(),
|
||||||
|
base_url: INITIAL_LITELLM_BASE_URL.to_string(),
|
||||||
|
// Credentials are deliberately never committed. Set this locally in
|
||||||
|
// ~/.galaxy/settings.toml before sending a request.
|
||||||
|
api_key: None,
|
||||||
|
models: vec![OpenAIModelConfig {
|
||||||
|
model_id: INITIAL_RIG_MODEL_ID.to_string(),
|
||||||
|
display_name: "Codex GPT-5.6 SOL (xhigh)".to_string(),
|
||||||
|
vision_supported: false,
|
||||||
|
context_size: default_context_size(),
|
||||||
|
max_input_tokens: None,
|
||||||
|
max_output_tokens: None,
|
||||||
|
provider: Some("openai".to_string()),
|
||||||
|
use_rig: true,
|
||||||
|
supports_system_messages: Some(false),
|
||||||
|
reasoning_efforts: Vec::new(),
|
||||||
|
enabled: true,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
default_chatgpt_provider(),
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cached metadata and runtime session options for an ACP agent.
|
/// Cached metadata and runtime session options for an ACP agent.
|
||||||
@@ -1349,6 +1453,17 @@ define_settings_group!(AISettings, settings: [
|
|||||||
description: "Identifier for the local Agent Client Protocol agent preset.",
|
description: "Identifier for the local Agent Client Protocol agent preset.",
|
||||||
feature_flag: FeatureFlag::AgentClientProtocol,
|
feature_flag: FeatureFlag::AgentClientProtocol,
|
||||||
}
|
}
|
||||||
|
// Friendly name shown for the configured ACP provider card.
|
||||||
|
acp_connection_name: AcpConnectionName {
|
||||||
|
type: String,
|
||||||
|
default: "ACP agent runtime".to_string(),
|
||||||
|
supported_platforms: SupportedPlatforms::OR(SupportedPlatforms::MAC.into(), SupportedPlatforms::LINUX.into()),
|
||||||
|
sync_to_cloud: SyncToCloud::Never,
|
||||||
|
private: false,
|
||||||
|
toml_path: "ai.acp.connection_name",
|
||||||
|
description: "Friendly name for the configured ACP agent runtime.",
|
||||||
|
feature_flag: FeatureFlag::AgentClientProtocol,
|
||||||
|
}
|
||||||
// Executable used to launch the configured local ACP agent.
|
// Executable used to launch the configured local ACP agent.
|
||||||
acp_agent_command: AcpAgentCommand {
|
acp_agent_command: AcpAgentCommand {
|
||||||
type: String,
|
type: String,
|
||||||
@@ -1394,6 +1509,16 @@ define_settings_group!(AISettings, settings: [
|
|||||||
}
|
}
|
||||||
// Authentication method for Bedrock: "profile", "static_keys", or "sso".
|
// Authentication method for Bedrock: "profile", "static_keys", or "sso".
|
||||||
bedrock_auth_method: BedrockAuthMethod,
|
bedrock_auth_method: BedrockAuthMethod,
|
||||||
|
// Friendly name shown for the configured Bedrock provider card.
|
||||||
|
bedrock_connection_name: BedrockConnectionName {
|
||||||
|
type: String,
|
||||||
|
default: "AWS Bedrock".to_string(),
|
||||||
|
supported_platforms: SupportedPlatforms::DESKTOP,
|
||||||
|
sync_to_cloud: SyncToCloud::Never,
|
||||||
|
private: false,
|
||||||
|
toml_path: "ai.bedrock.connection_name",
|
||||||
|
description: "Friendly name for the configured AWS Bedrock connection.",
|
||||||
|
}
|
||||||
// AWS profile name to use when auth_method is Profile or SSO.
|
// AWS profile name to use when auth_method is Profile or SSO.
|
||||||
bedrock_profile: BedrockProfile {
|
bedrock_profile: BedrockProfile {
|
||||||
type: String,
|
type: String,
|
||||||
@@ -1424,7 +1549,7 @@ define_settings_group!(AISettings, settings: [
|
|||||||
toml_path: "ai.bedrock.cross_region_inference",
|
toml_path: "ai.bedrock.cross_region_inference",
|
||||||
description: "Whether to automatically add cross-region inference prefixes to model IDs.",
|
description: "Whether to automatically add cross-region inference prefixes to model IDs.",
|
||||||
}
|
}
|
||||||
// Custom Bedrock model configurations.
|
// Cached Bedrock models that passed foundation-model availability checks.
|
||||||
bedrock_models: BedrockModels {
|
bedrock_models: BedrockModels {
|
||||||
type: Vec<BedrockModelConfig>,
|
type: Vec<BedrockModelConfig>,
|
||||||
default: Vec::new(),
|
default: Vec::new(),
|
||||||
@@ -1432,7 +1557,7 @@ define_settings_group!(AISettings, settings: [
|
|||||||
sync_to_cloud: SyncToCloud::Never,
|
sync_to_cloud: SyncToCloud::Never,
|
||||||
private: false,
|
private: false,
|
||||||
toml_path: "ai.bedrock.models",
|
toml_path: "ai.bedrock.models",
|
||||||
description: "Custom AWS Bedrock model configurations.",
|
description: "AWS Bedrock models discovered as authorized and available in the configured region.",
|
||||||
}
|
}
|
||||||
// Whether to automatically run the login command when Bedrock credentials expire.
|
// Whether to automatically run the login command when Bedrock credentials expire.
|
||||||
bedrock_auto_login: BedrockAutoLogin {
|
bedrock_auto_login: BedrockAutoLogin {
|
||||||
|
|||||||
@@ -349,8 +349,9 @@ fn test_toolbar_command_map_roundtrip() {
|
|||||||
fn initial_litellm_provider_maps_codex_model_to_rig_without_a_committed_key() {
|
fn initial_litellm_provider_maps_codex_model_to_rig_without_a_committed_key() {
|
||||||
let providers = default_openai_providers();
|
let providers = default_openai_providers();
|
||||||
|
|
||||||
assert_eq!(providers.len(), 1);
|
assert_eq!(providers.len(), 2);
|
||||||
let provider = &providers[0];
|
let provider = &providers[0];
|
||||||
|
assert_eq!(provider.kind, OpenAIProviderKind::OpenAICompatible);
|
||||||
assert_eq!(provider.base_url, INITIAL_LITELLM_BASE_URL);
|
assert_eq!(provider.base_url, INITIAL_LITELLM_BASE_URL);
|
||||||
assert_eq!(provider.api_key, None);
|
assert_eq!(provider.api_key, None);
|
||||||
assert_eq!(provider.models.len(), 1);
|
assert_eq!(provider.models.len(), 1);
|
||||||
@@ -359,6 +360,67 @@ fn initial_litellm_provider_maps_codex_model_to_rig_without_a_committed_key() {
|
|||||||
assert!(model.use_rig);
|
assert!(model.use_rig);
|
||||||
assert_eq!(model.supports_system_messages, Some(false));
|
assert_eq!(model.supports_system_messages, Some(false));
|
||||||
assert!(!model.supports_system_messages());
|
assert!(!model.supports_system_messages());
|
||||||
|
|
||||||
|
let chatgpt = &providers[1];
|
||||||
|
assert_eq!(chatgpt.kind, OpenAIProviderKind::ChatGPTSubscription);
|
||||||
|
assert_eq!(chatgpt.name, "ChatGPT Subscription");
|
||||||
|
assert!(chatgpt.base_url.is_empty());
|
||||||
|
assert!(chatgpt.api_key.is_none());
|
||||||
|
assert!(chatgpt
|
||||||
|
.models
|
||||||
|
.iter()
|
||||||
|
.any(|model| model.model_id == "gpt-5.4-pro"));
|
||||||
|
|
||||||
|
let sol = chatgpt
|
||||||
|
.models
|
||||||
|
.iter()
|
||||||
|
.find(|model| model.model_id == "gpt-5.6-sol")
|
||||||
|
.expect("GPT-5.6 Sol should be in the ChatGPT catalog");
|
||||||
|
assert_eq!(sol.reasoning_efforts.len(), 6);
|
||||||
|
assert!(sol.reasoning_efforts.iter().any(|effort| effort == "max"));
|
||||||
|
assert!(sol.reasoning_efforts.iter().any(|effort| effort == "ultra"));
|
||||||
|
|
||||||
|
let luna = chatgpt
|
||||||
|
.models
|
||||||
|
.iter()
|
||||||
|
.find(|model| model.model_id == "gpt-5.6-luna")
|
||||||
|
.expect("GPT-5.6 Luna should be in the ChatGPT catalog");
|
||||||
|
assert!(luna.reasoning_efforts.iter().any(|effort| effort == "max"));
|
||||||
|
assert!(luna
|
||||||
|
.reasoning_efforts
|
||||||
|
.iter()
|
||||||
|
.any(|effort| effort == "ultra"));
|
||||||
|
|
||||||
|
let terra = chatgpt
|
||||||
|
.models
|
||||||
|
.iter()
|
||||||
|
.find(|model| model.model_id == "gpt-5.6-terra")
|
||||||
|
.expect("GPT-5.6 Terra should be in the ChatGPT catalog");
|
||||||
|
assert!(terra.reasoning_efforts.iter().any(|effort| effort == "max"));
|
||||||
|
assert!(terra
|
||||||
|
.reasoning_efforts
|
||||||
|
.iter()
|
||||||
|
.any(|effort| effort == "ultra"));
|
||||||
|
|
||||||
|
let gpt_54 = chatgpt
|
||||||
|
.models
|
||||||
|
.iter()
|
||||||
|
.find(|model| model.model_id == "gpt-5.4")
|
||||||
|
.expect("GPT-5.4 should be in the ChatGPT catalog");
|
||||||
|
assert_eq!(
|
||||||
|
gpt_54.reasoning_efforts,
|
||||||
|
vec!["low", "medium", "high", "xhigh"]
|
||||||
|
.into_iter()
|
||||||
|
.map(str::to_string)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
);
|
||||||
|
|
||||||
|
let instant = chatgpt
|
||||||
|
.models
|
||||||
|
.iter()
|
||||||
|
.find(|model| model.model_id == "gpt-5.3-instant")
|
||||||
|
.expect("GPT-5.3 Instant should be in the ChatGPT catalog");
|
||||||
|
assert!(instant.reasoning_efforts.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+664
-1252
File diff suppressed because it is too large
Load Diff
@@ -86,6 +86,7 @@ mod platform;
|
|||||||
mod platform_page;
|
mod platform_page;
|
||||||
mod privacy;
|
mod privacy;
|
||||||
mod privacy_page;
|
mod privacy_page;
|
||||||
|
mod provider_setup_modal;
|
||||||
mod scripting_page;
|
mod scripting_page;
|
||||||
mod set_default_model_modal;
|
mod set_default_model_modal;
|
||||||
mod settings_file_footer;
|
mod settings_file_footer;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -113,45 +113,45 @@ const DRACULA_BRIGHT_COLORS: AnsiColors = AnsiColors::new(
|
|||||||
AnsiColor::from_u32(0xFFFFFFFF),
|
AnsiColor::from_u32(0xFFFFFFFF),
|
||||||
);
|
);
|
||||||
const GALAXY_DARK_NORMAL_COLORS: AnsiColors = AnsiColors::new(
|
const GALAXY_DARK_NORMAL_COLORS: AnsiColors = AnsiColors::new(
|
||||||
AnsiColor::from_u32(0x3A4050FF),
|
AnsiColor::from_u32(0x444B61FF),
|
||||||
AnsiColor::from_u32(0xF07178FF),
|
AnsiColor::from_u32(0xF27A86FF),
|
||||||
AnsiColor::from_u32(0x65B88AFF),
|
AnsiColor::from_u32(0x68C093FF),
|
||||||
AnsiColor::from_u32(0xDAB965FF),
|
AnsiColor::from_u32(0xDFB968FF),
|
||||||
AnsiColor::from_u32(0x6F8EFFFF),
|
AnsiColor::from_u32(0x7396FFFF),
|
||||||
AnsiColor::from_u32(0xB38CF3FF),
|
AnsiColor::from_u32(0xC184F4FF),
|
||||||
AnsiColor::from_u32(0x62B8C8FF),
|
AnsiColor::from_u32(0x62C2D1FF),
|
||||||
AnsiColor::from_u32(0xD9DCE8FF),
|
AnsiColor::from_u32(0xDDE1EDFF),
|
||||||
);
|
);
|
||||||
const GALAXY_DARK_BRIGHT_COLORS: AnsiColors = AnsiColors::new(
|
const GALAXY_DARK_BRIGHT_COLORS: AnsiColors = AnsiColors::new(
|
||||||
AnsiColor::from_u32(0x60687AFF),
|
AnsiColor::from_u32(0x707A93FF),
|
||||||
AnsiColor::from_u32(0xFF8B91FF),
|
AnsiColor::from_u32(0xFF99A3FF),
|
||||||
AnsiColor::from_u32(0x7DCB9FFF),
|
AnsiColor::from_u32(0x86D6ADFF),
|
||||||
AnsiColor::from_u32(0xE8CC7EFF),
|
AnsiColor::from_u32(0xF0D184FF),
|
||||||
AnsiColor::from_u32(0x91A6FFFF),
|
AnsiColor::from_u32(0x9EB7FFFF),
|
||||||
AnsiColor::from_u32(0xC9A9FFFF),
|
AnsiColor::from_u32(0xD8A8FFFF),
|
||||||
AnsiColor::from_u32(0x7CCDDDFF),
|
AnsiColor::from_u32(0x83D8E4FF),
|
||||||
AnsiColor::from_u32(0xFAFAFDFF),
|
AnsiColor::from_u32(0xFCFBFFFF),
|
||||||
);
|
);
|
||||||
|
|
||||||
const GALAXY_DAY_NORMAL_COLORS: AnsiColors = AnsiColors::new(
|
const GALAXY_DAY_NORMAL_COLORS: AnsiColors = AnsiColors::new(
|
||||||
AnsiColor::from_u32(0x4D5363FF),
|
AnsiColor::from_u32(0x50566BFF),
|
||||||
AnsiColor::from_u32(0xB64B59FF),
|
AnsiColor::from_u32(0xB94E64FF),
|
||||||
AnsiColor::from_u32(0x317C5AFF),
|
AnsiColor::from_u32(0x2F8060FF),
|
||||||
AnsiColor::from_u32(0x886B2EFF),
|
AnsiColor::from_u32(0x89682AFF),
|
||||||
AnsiColor::from_u32(0x435FC7FF),
|
AnsiColor::from_u32(0x435FC8FF),
|
||||||
AnsiColor::from_u32(0x7653A8FF),
|
AnsiColor::from_u32(0x8154A8FF),
|
||||||
AnsiColor::from_u32(0x347789FF),
|
AnsiColor::from_u32(0x2F7D8BFF),
|
||||||
AnsiColor::from_u32(0xD3D6DFFF),
|
AnsiColor::from_u32(0xD0D5E2FF),
|
||||||
);
|
);
|
||||||
const GALAXY_DAY_BRIGHT_COLORS: AnsiColors = AnsiColors::new(
|
const GALAXY_DAY_BRIGHT_COLORS: AnsiColors = AnsiColors::new(
|
||||||
AnsiColor::from_u32(0x697080FF),
|
AnsiColor::from_u32(0x6F778EFF),
|
||||||
AnsiColor::from_u32(0xCB606BFF),
|
AnsiColor::from_u32(0xD06374FF),
|
||||||
AnsiColor::from_u32(0x3E9169FF),
|
AnsiColor::from_u32(0x43966FFF),
|
||||||
AnsiColor::from_u32(0xA07D37FF),
|
AnsiColor::from_u32(0xA17C35FF),
|
||||||
AnsiColor::from_u32(0x5D76DBFF),
|
AnsiColor::from_u32(0x607ADEFF),
|
||||||
AnsiColor::from_u32(0x8C6CBCFF),
|
AnsiColor::from_u32(0x9B6EBFFF),
|
||||||
AnsiColor::from_u32(0x468C9EFF),
|
AnsiColor::from_u32(0x4693A1FF),
|
||||||
AnsiColor::from_u32(0xF6F7FAFF),
|
AnsiColor::from_u32(0xF7F7FBFF),
|
||||||
);
|
);
|
||||||
|
|
||||||
const PHENOMENON_NORMAL_COLORS: AnsiColors = AnsiColors::new(
|
const PHENOMENON_NORMAL_COLORS: AnsiColors = AnsiColors::new(
|
||||||
@@ -665,9 +665,15 @@ pub(super) fn adeberry() -> GalaxyTheme {
|
|||||||
|
|
||||||
pub(super) fn galaxy_dark() -> GalaxyTheme {
|
pub(super) fn galaxy_dark() -> GalaxyTheme {
|
||||||
GalaxyTheme::new(
|
GalaxyTheme::new(
|
||||||
Fill::Solid(ColorU::from_u32(0x1B1E2BFF)),
|
Fill::VerticalGradient(VerticalGradient::new(
|
||||||
ColorU::from_u32(0xF2F3FAFF),
|
ColorU::from_u32(0x252A46FF),
|
||||||
Fill::Solid(ColorU::from_u32(0x7C83FFFF)),
|
ColorU::from_u32(0x171925FF),
|
||||||
|
)),
|
||||||
|
ColorU::from_u32(0xF4F3FBFF),
|
||||||
|
Fill::HorizontalGradient(HorizontalGradient::new(
|
||||||
|
ColorU::from_u32(0x6F8BFFFF),
|
||||||
|
ColorU::from_u32(0xAE74E6FF),
|
||||||
|
)),
|
||||||
None,
|
None,
|
||||||
Some(Details::Darker),
|
Some(Details::Darker),
|
||||||
galaxy_dark_colors(),
|
galaxy_dark_colors(),
|
||||||
@@ -678,9 +684,15 @@ pub(super) fn galaxy_dark() -> GalaxyTheme {
|
|||||||
|
|
||||||
pub(super) fn galaxy_day() -> GalaxyTheme {
|
pub(super) fn galaxy_day() -> GalaxyTheme {
|
||||||
GalaxyTheme::new(
|
GalaxyTheme::new(
|
||||||
Fill::Solid(ColorU::from_u32(0xE1E4EBFF)),
|
Fill::VerticalGradient(VerticalGradient::new(
|
||||||
ColorU::from_u32(0x242735FF),
|
ColorU::from_u32(0xECECF4FF),
|
||||||
Fill::Solid(ColorU::from_u32(0x5765D8FF)),
|
ColorU::from_u32(0xD6DCE8FF),
|
||||||
|
)),
|
||||||
|
ColorU::from_u32(0x29283AFF),
|
||||||
|
Fill::HorizontalGradient(HorizontalGradient::new(
|
||||||
|
ColorU::from_u32(0x5168D6FF),
|
||||||
|
ColorU::from_u32(0x8C64B8FF),
|
||||||
|
)),
|
||||||
None,
|
None,
|
||||||
Some(Details::Lighter),
|
Some(Details::Lighter),
|
||||||
galaxy_day_colors(),
|
galaxy_day_colors(),
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ use crate::auth::{AuthStateProvider, UserUid};
|
|||||||
use crate::channel::{Channel, ChannelState};
|
use crate::channel::{Channel, ChannelState};
|
||||||
use crate::cloud_object::model::persistence::CloudModel;
|
use crate::cloud_object::model::persistence::CloudModel;
|
||||||
use crate::cloud_object::{CloudObjectEventEntrypoint, ObjectType, Owner, Space};
|
use crate::cloud_object::{CloudObjectEventEntrypoint, ObjectType, Owner, Space};
|
||||||
|
use crate::local_object_repository::local_owner;
|
||||||
use crate::pricing::PricingInfoModel;
|
use crate::pricing::PricingInfoModel;
|
||||||
use crate::report_error;
|
use crate::report_error;
|
||||||
use crate::server::experiments::{ServerExperiment, ServerExperiments, ServerExperimentsEvent};
|
use crate::server::experiments::{ServerExperiment, ServerExperiments, ServerExperimentsEvent};
|
||||||
@@ -673,6 +674,12 @@ impl UserWorkspaces {
|
|||||||
// Returns a Vec of the user's active spaces, based on their
|
// Returns a Vec of the user's active spaces, based on their
|
||||||
// team membership. Includes the "Personal Space" by default.
|
// team membership. Includes the "Personal Space" by default.
|
||||||
pub fn all_user_spaces(&self, ctx: &AppContext) -> Vec<Space> {
|
pub fn all_user_spaces(&self, ctx: &AppContext) -> Vec<Space> {
|
||||||
|
// Galaxy's OSS channel is local-first. It has no authenticated cloud
|
||||||
|
// identity or shared drive, so never expose the legacy shared space.
|
||||||
|
if ChannelState::channel().is_local_first() {
|
||||||
|
return vec![Space::Personal];
|
||||||
|
}
|
||||||
|
|
||||||
if AuthStateProvider::as_ref(ctx)
|
if AuthStateProvider::as_ref(ctx)
|
||||||
.get()
|
.get()
|
||||||
.is_user_web_anonymous_user()
|
.is_user_web_anonymous_user()
|
||||||
@@ -695,8 +702,12 @@ impl UserWorkspaces {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Returns the [`Owner`] for the user's personal drive. If the user is not authenticated, this
|
// Returns the [`Owner`] for the user's personal drive. If the user is not authenticated, this
|
||||||
// returns `None`.
|
// returns the stable local owner in local-first channels.
|
||||||
pub fn personal_drive(&self, ctx: &AppContext) -> Option<Owner> {
|
pub fn personal_drive(&self, ctx: &AppContext) -> Option<Owner> {
|
||||||
|
if ChannelState::channel().is_local_first() {
|
||||||
|
return Some(local_owner());
|
||||||
|
}
|
||||||
|
|
||||||
// Return the authenticated user's ID if available, otherwise provide a
|
// Return the authenticated user's ID if available, otherwise provide a
|
||||||
// synthetic local owner so cloud objects (rules, etc.) can be created and
|
// synthetic local owner so cloud objects (rules, etc.) can be created and
|
||||||
// stored locally without requiring Warp authentication.
|
// stored locally without requiring Warp authentication.
|
||||||
@@ -724,8 +735,24 @@ impl UserWorkspaces {
|
|||||||
// Maps an [`Owner`] into a [`Space`], based on the user's team memberships.
|
// Maps an [`Owner`] into a [`Space`], based on the user's team memberships.
|
||||||
// This is always possible, as unknown owners imply the shared space.
|
// This is always possible, as unknown owners imply the shared space.
|
||||||
pub fn owner_to_space(&self, owner: Owner, ctx: &AppContext) -> Space {
|
pub fn owner_to_space(&self, owner: Owner, ctx: &AppContext) -> Space {
|
||||||
|
if ChannelState::channel().is_local_first() {
|
||||||
|
return if owner == local_owner() {
|
||||||
|
Space::Personal
|
||||||
|
} else {
|
||||||
|
Space::Shared
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
match owner {
|
match owner {
|
||||||
Owner::User { user_uid } => {
|
Owner::User { user_uid } => {
|
||||||
|
if matches!(
|
||||||
|
local_owner(),
|
||||||
|
Owner::User {
|
||||||
|
user_uid: local_uid
|
||||||
|
} if local_uid == user_uid
|
||||||
|
) {
|
||||||
|
return Space::Personal;
|
||||||
|
}
|
||||||
if !FeatureFlag::SharedWithMe.is_enabled() {
|
if !FeatureFlag::SharedWithMe.is_enabled() {
|
||||||
return Space::Personal;
|
return Space::Personal;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use crate::ai::llms::LLMModelHost;
|
|||||||
use crate::auth::AuthManager;
|
use crate::auth::AuthManager;
|
||||||
use crate::cloud_object::model::persistence::CloudModel;
|
use crate::cloud_object::model::persistence::CloudModel;
|
||||||
use crate::features::FeatureFlag;
|
use crate::features::FeatureFlag;
|
||||||
|
use crate::local_object_repository::local_owner;
|
||||||
use crate::network::NetworkStatus;
|
use crate::network::NetworkStatus;
|
||||||
use crate::server::cloud_objects::update_manager::UpdateManager;
|
use crate::server::cloud_objects::update_manager::UpdateManager;
|
||||||
use crate::server::ids::ClientId;
|
use crate::server::ids::ClientId;
|
||||||
@@ -96,6 +97,39 @@ fn initialize_app_with_auth(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn oss_exposes_only_local_personal_space() {
|
||||||
|
App::test((), |mut app| async move {
|
||||||
|
app.add_singleton_model(|ctx| {
|
||||||
|
UserWorkspaces::mock(
|
||||||
|
Arc::new(MockTeamClient::new()),
|
||||||
|
Arc::new(MockWorkspaceClient::new()),
|
||||||
|
vec![],
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
app.read(|ctx| {
|
||||||
|
let user_workspaces = UserWorkspaces::as_ref(ctx);
|
||||||
|
assert_eq!(user_workspaces.all_user_spaces(ctx), vec![Space::Personal]);
|
||||||
|
assert_eq!(user_workspaces.personal_drive(ctx), Some(local_owner()));
|
||||||
|
assert_eq!(
|
||||||
|
user_workspaces.space_to_owner(Space::Personal, ctx),
|
||||||
|
Some(local_owner())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
user_workspaces.owner_to_space(
|
||||||
|
Owner::User {
|
||||||
|
user_uid: UserUid::new("legacy-cloud-user"),
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
),
|
||||||
|
Space::Shared
|
||||||
|
);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_loading_all_spaces_after_switching_from_offline() {
|
fn test_loading_all_spaces_after_switching_from_offline() {
|
||||||
let _flag = FeatureFlag::KnowledgeSidebar.override_enabled(true);
|
let _flag = FeatureFlag::KnowledgeSidebar.override_enabled(true);
|
||||||
|
|||||||
@@ -150,12 +150,14 @@ impl AIAgentActionResultType {
|
|||||||
..
|
..
|
||||||
} => command_result_content(Some(command), output, exit_code.value()),
|
} => command_result_content(Some(command), output, exit_code.value()),
|
||||||
RequestCommandOutputResult::LongRunningCommandSnapshot {
|
RequestCommandOutputResult::LongRunningCommandSnapshot {
|
||||||
|
block_id,
|
||||||
command,
|
command,
|
||||||
grid_contents,
|
grid_contents,
|
||||||
cursor,
|
cursor,
|
||||||
is_alt_screen_active,
|
is_alt_screen_active,
|
||||||
..
|
..
|
||||||
} => shell_snapshot_content(
|
} => shell_snapshot_content(
|
||||||
|
block_id,
|
||||||
Some(command),
|
Some(command),
|
||||||
grid_contents,
|
grid_contents,
|
||||||
cursor,
|
cursor,
|
||||||
@@ -167,12 +169,14 @@ impl AIAgentActionResultType {
|
|||||||
},
|
},
|
||||||
Self::WriteToLongRunningShellCommand(result) => match result {
|
Self::WriteToLongRunningShellCommand(result) => match result {
|
||||||
WriteToLongRunningShellCommandResult::Snapshot {
|
WriteToLongRunningShellCommandResult::Snapshot {
|
||||||
|
block_id,
|
||||||
grid_contents,
|
grid_contents,
|
||||||
cursor,
|
cursor,
|
||||||
is_alt_screen_active,
|
is_alt_screen_active,
|
||||||
is_preempted,
|
is_preempted,
|
||||||
..
|
..
|
||||||
} => shell_snapshot_content(
|
} => shell_snapshot_content(
|
||||||
|
block_id,
|
||||||
None,
|
None,
|
||||||
grid_contents,
|
grid_contents,
|
||||||
cursor,
|
cursor,
|
||||||
@@ -229,6 +233,7 @@ impl AIAgentActionResultType {
|
|||||||
..
|
..
|
||||||
} => command_result_content(Some(command), output, exit_code.value()),
|
} => command_result_content(Some(command), output, exit_code.value()),
|
||||||
ReadShellCommandOutputResult::LongRunningCommandSnapshot {
|
ReadShellCommandOutputResult::LongRunningCommandSnapshot {
|
||||||
|
block_id,
|
||||||
command,
|
command,
|
||||||
grid_contents,
|
grid_contents,
|
||||||
cursor,
|
cursor,
|
||||||
@@ -236,6 +241,7 @@ impl AIAgentActionResultType {
|
|||||||
is_preempted,
|
is_preempted,
|
||||||
..
|
..
|
||||||
} => shell_snapshot_content(
|
} => shell_snapshot_content(
|
||||||
|
block_id,
|
||||||
Some(command),
|
Some(command),
|
||||||
grid_contents,
|
grid_contents,
|
||||||
cursor,
|
cursor,
|
||||||
@@ -247,6 +253,7 @@ impl AIAgentActionResultType {
|
|||||||
},
|
},
|
||||||
Self::TransferShellCommandControlToUser(result) => match result {
|
Self::TransferShellCommandControlToUser(result) => match result {
|
||||||
TransferShellCommandControlToUserResult::Snapshot {
|
TransferShellCommandControlToUserResult::Snapshot {
|
||||||
|
block_id,
|
||||||
grid_contents,
|
grid_contents,
|
||||||
cursor,
|
cursor,
|
||||||
is_alt_screen_active,
|
is_alt_screen_active,
|
||||||
@@ -255,6 +262,7 @@ impl AIAgentActionResultType {
|
|||||||
} => format!(
|
} => format!(
|
||||||
"{}\nControl has been transferred to the user. Do not write to the command until control is returned.",
|
"{}\nControl has been transferred to the user. Do not write to the command until control is returned.",
|
||||||
shell_snapshot_content(
|
shell_snapshot_content(
|
||||||
|
block_id,
|
||||||
None,
|
None,
|
||||||
grid_contents,
|
grid_contents,
|
||||||
cursor,
|
cursor,
|
||||||
@@ -305,6 +313,7 @@ fn command_result_content(command: Option<&str>, output: &str, exit_code: i32) -
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn shell_snapshot_content(
|
fn shell_snapshot_content(
|
||||||
|
block_id: &BlockId,
|
||||||
command: Option<&str>,
|
command: Option<&str>,
|
||||||
grid_contents: &str,
|
grid_contents: &str,
|
||||||
cursor: &str,
|
cursor: &str,
|
||||||
@@ -318,7 +327,7 @@ fn shell_snapshot_content(
|
|||||||
.map(|is_preempted| format!("\nPreempted: {is_preempted}"))
|
.map(|is_preempted| format!("\nPreempted: {is_preempted}"))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
format!(
|
format!(
|
||||||
"{command}Command is still running.\nCurrent output:\n{grid_contents}\nCursor: {cursor}\nAlt screen active: {is_alt_screen_active}{preempted}"
|
"{command}Command is still running.\nCommand ID: {block_id}\nCurrent output:\n{grid_contents}\nCursor: {cursor}\nAlt screen active: {is_alt_screen_active}{preempted}\nContinue monitoring with `read_shell_command_output` using command_id `{block_id}` and a short wait. Do not report completion while the command is still running."
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use futures::StreamExt;
|
||||||
|
use galaxy_agent_core::{
|
||||||
|
AgentError, AgentErrorKind, AgentEvent, AgentEventStream, AgentRuntime, ConversationMessage,
|
||||||
|
MessageContent, MessageRole, RuntimeCapabilities, RuntimeDescriptor, RuntimeKind, TurnControl,
|
||||||
|
TurnRequest, turn_control,
|
||||||
|
};
|
||||||
|
use rig_core::client::CompletionClient;
|
||||||
|
use rig_core::providers::chatgpt;
|
||||||
|
|
||||||
|
use crate::request::build_completion_request;
|
||||||
|
use crate::stream::start_model_turn;
|
||||||
|
|
||||||
|
/// The information a user needs to complete ChatGPT's device authorization flow.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct ChatGPTDeviceCode {
|
||||||
|
pub verification_uri: String,
|
||||||
|
pub user_code: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Small application-facing wrapper around Rig's native ChatGPT OAuth client.
|
||||||
|
///
|
||||||
|
/// Keeping the Rig auth type behind this wrapper lets Galaxy present device-code
|
||||||
|
/// instructions without depending on Rig's private auth module.
|
||||||
|
pub struct ChatGPTSubscriptionClient {
|
||||||
|
client: chatgpt::Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChatGPTSubscriptionClient {
|
||||||
|
pub fn with_device_code_handler<F>(handler: F) -> Result<Self, String>
|
||||||
|
where
|
||||||
|
F: Fn(ChatGPTDeviceCode) + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
let client = chatgpt::Client::builder()
|
||||||
|
.oauth()
|
||||||
|
.on_device_code(move |prompt| {
|
||||||
|
handler(ChatGPTDeviceCode {
|
||||||
|
verification_uri: prompt.verification_uri,
|
||||||
|
user_code: prompt.user_code,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.build()
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
Ok(Self { client })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn authorize(&self) -> Result<(), String> {
|
||||||
|
self.client
|
||||||
|
.authorize()
|
||||||
|
.await
|
||||||
|
.map_err(|error| error.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct ChatGPTSubscriptionRuntimeConfig {
|
||||||
|
pub model: String,
|
||||||
|
pub reasoning_effort: Option<String>,
|
||||||
|
pub max_output_tokens: Option<u64>,
|
||||||
|
pub auth_file: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct ChatGPTSubscriptionRuntime {
|
||||||
|
config: ChatGPTSubscriptionRuntimeConfig,
|
||||||
|
descriptor: RuntimeDescriptor,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reasoning_additional_params(reasoning_effort: Option<&str>) -> Option<serde_json::Value> {
|
||||||
|
reasoning_effort.map(|effort| {
|
||||||
|
// Codex exposes `ultra` as a client-side delegation mode, while the
|
||||||
|
// Responses wire format carries its maximum reasoning as `max`.
|
||||||
|
let wire_effort = if effort == "ultra" { "max" } else { effort };
|
||||||
|
serde_json::json!({"reasoning": {"effort": wire_effort}})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChatGPTSubscriptionRuntime {
|
||||||
|
pub fn new(config: ChatGPTSubscriptionRuntimeConfig) -> Self {
|
||||||
|
let descriptor = RuntimeDescriptor {
|
||||||
|
id: format!("rig-chatgpt-subscription:{}", config.model),
|
||||||
|
display_name: format!("ChatGPT Subscription / {}", config.model),
|
||||||
|
kind: RuntimeKind::Provider,
|
||||||
|
capabilities: RuntimeCapabilities::provider(),
|
||||||
|
};
|
||||||
|
Self { config, descriptor }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Completes a text-only request for callers such as crosscheck reviewers.
|
||||||
|
pub async fn complete_text(
|
||||||
|
&self,
|
||||||
|
system_prompt: String,
|
||||||
|
user_prompt: String,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let mut request = TurnRequest::new(
|
||||||
|
self.config.model.clone(),
|
||||||
|
vec![ConversationMessage {
|
||||||
|
role: MessageRole::User,
|
||||||
|
content: MessageContent::Text(user_prompt),
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
request.system_prompt = Some(system_prompt);
|
||||||
|
request.max_output_tokens = self.config.max_output_tokens;
|
||||||
|
|
||||||
|
let (_sender, control) = turn_control();
|
||||||
|
let mut stream = self
|
||||||
|
.start_turn(request, control)
|
||||||
|
.await
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
let mut output = String::new();
|
||||||
|
while let Some(event) = stream.next().await {
|
||||||
|
match event.map_err(|error| error.to_string())? {
|
||||||
|
AgentEvent::TextDelta { text } => output.push_str(&text),
|
||||||
|
AgentEvent::ReasoningDelta { .. }
|
||||||
|
| AgentEvent::ReasoningCompleted { .. }
|
||||||
|
| AgentEvent::TurnStarted { .. }
|
||||||
|
| AgentEvent::UsageUpdated { .. }
|
||||||
|
| AgentEvent::RuntimeActivityUpdated { .. }
|
||||||
|
| AgentEvent::ContextUsageUpdated { .. }
|
||||||
|
| AgentEvent::UserInputAccepted { .. }
|
||||||
|
| AgentEvent::RuntimeNotice { .. }
|
||||||
|
| AgentEvent::TurnStopped { .. }
|
||||||
|
| AgentEvent::Tool { .. } => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if output.is_empty() {
|
||||||
|
return Err("ChatGPT returned an empty response.".to_string());
|
||||||
|
}
|
||||||
|
Ok(output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl AgentRuntime for ChatGPTSubscriptionRuntime {
|
||||||
|
fn descriptor(&self) -> &RuntimeDescriptor {
|
||||||
|
&self.descriptor
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn start_turn(
|
||||||
|
&self,
|
||||||
|
request: TurnRequest,
|
||||||
|
control: TurnControl,
|
||||||
|
) -> Result<AgentEventStream, AgentError> {
|
||||||
|
let mut builder = chatgpt::Client::builder().oauth();
|
||||||
|
if let Some(auth_file) = &self.config.auth_file {
|
||||||
|
builder = builder.auth_file(auth_file);
|
||||||
|
}
|
||||||
|
let client = builder
|
||||||
|
.build()
|
||||||
|
.map_err(|error| AgentError::new(AgentErrorKind::Configuration, error.to_string()))?;
|
||||||
|
let model = client.completion_model(&self.config.model);
|
||||||
|
let max_output_tokens = request.max_output_tokens.or(self.config.max_output_tokens);
|
||||||
|
let additional_params =
|
||||||
|
reasoning_additional_params(self.config.reasoning_effort.as_deref());
|
||||||
|
let completion_request = build_completion_request(
|
||||||
|
request,
|
||||||
|
self.config.max_output_tokens,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
additional_params,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
start_model_turn(model, completion_request, control, max_output_tokens).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_descriptor_identifies_chatgpt_subscription() {
|
||||||
|
let runtime = ChatGPTSubscriptionRuntime::new(ChatGPTSubscriptionRuntimeConfig {
|
||||||
|
model: "gpt-5.3-codex".to_string(),
|
||||||
|
reasoning_effort: Some("high".to_string()),
|
||||||
|
max_output_tokens: None,
|
||||||
|
auth_file: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
runtime.descriptor().id,
|
||||||
|
"rig-chatgpt-subscription:gpt-5.3-codex"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reasoning_effort_is_encoded_for_the_responses_request() {
|
||||||
|
let request = TurnRequest::new(
|
||||||
|
"gpt-5.4".to_string(),
|
||||||
|
vec![ConversationMessage {
|
||||||
|
role: MessageRole::User,
|
||||||
|
content: MessageContent::Text("hello".to_string()),
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
let request = build_completion_request(
|
||||||
|
request,
|
||||||
|
None,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
reasoning_additional_params(Some("xhigh")),
|
||||||
|
)
|
||||||
|
.expect("request should convert");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
request.additional_params,
|
||||||
|
reasoning_additional_params(Some("xhigh"))
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
reasoning_additional_params(Some("ultra")),
|
||||||
|
Some(serde_json::json!({
|
||||||
|
"reasoning": { "effort": "max" }
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
//! Rig-backed implementations of Galaxy's provider-neutral agent runtime.
|
//! Rig-backed implementations of Galaxy's provider-neutral agent runtime.
|
||||||
|
|
||||||
mod bedrock;
|
mod bedrock;
|
||||||
|
mod chatgpt;
|
||||||
mod openai_compatible;
|
mod openai_compatible;
|
||||||
mod request;
|
mod request;
|
||||||
mod stream;
|
mod stream;
|
||||||
|
|
||||||
pub use bedrock::*;
|
pub use bedrock::*;
|
||||||
|
pub use chatgpt::*;
|
||||||
pub use openai_compatible::*;
|
pub use openai_compatible::*;
|
||||||
|
|||||||
@@ -276,6 +276,7 @@ fn request_conversion_preserves_tool_call_and_denied_result_for_the_next_turn()
|
|||||||
panic!("expected assistant tool call content");
|
panic!("expected assistant tool call content");
|
||||||
};
|
};
|
||||||
assert_eq!(call.id, "call-1");
|
assert_eq!(call.id, "call-1");
|
||||||
|
assert_eq!(call.call_id.as_deref(), Some("call-1"));
|
||||||
assert_eq!(call.function.name, "run_shell_command");
|
assert_eq!(call.function.name, "run_shell_command");
|
||||||
|
|
||||||
let Message::User { content } = messages[1] else {
|
let Message::User { content } = messages[1] else {
|
||||||
@@ -285,6 +286,7 @@ fn request_conversion_preserves_tool_call_and_denied_result_for_the_next_turn()
|
|||||||
panic!("expected user tool result content");
|
panic!("expected user tool result content");
|
||||||
};
|
};
|
||||||
assert_eq!(result.id, "call-1");
|
assert_eq!(result.id, "call-1");
|
||||||
|
assert_eq!(result.call_id.as_deref(), Some("call-1"));
|
||||||
let Some(ToolResultContent::Text(text)) = result.content.iter().next() else {
|
let Some(ToolResultContent::Text(text)) = result.content.iter().next() else {
|
||||||
panic!("expected text tool result");
|
panic!("expected text tool result");
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ pub(crate) fn build_completion_request(
|
|||||||
tool_choice: None,
|
tool_choice: None,
|
||||||
additional_params,
|
additional_params,
|
||||||
output_schema: None,
|
output_schema: None,
|
||||||
|
record_telemetry_content: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,7 +88,8 @@ fn user_content(
|
|||||||
tool_use_id,
|
tool_use_id,
|
||||||
content,
|
content,
|
||||||
is_error,
|
is_error,
|
||||||
} => vec![UserContent::tool_result(
|
} => vec![UserContent::tool_result_with_call_id(
|
||||||
|
tool_use_id.clone(),
|
||||||
tool_use_id,
|
tool_use_id,
|
||||||
OneOrMany::one(ToolResultContent::text(tool_result_text(content, is_error))),
|
OneOrMany::one(ToolResultContent::text(tool_result_text(content, is_error))),
|
||||||
)],
|
)],
|
||||||
@@ -112,7 +114,12 @@ fn assistant_content(
|
|||||||
tool_use_id,
|
tool_use_id,
|
||||||
name,
|
name,
|
||||||
input,
|
input,
|
||||||
} => vec![AssistantContent::tool_call(tool_use_id, name, input)],
|
} => vec![AssistantContent::tool_call_with_call_id(
|
||||||
|
tool_use_id.clone(),
|
||||||
|
tool_use_id,
|
||||||
|
name,
|
||||||
|
input,
|
||||||
|
)],
|
||||||
MessageContent::MultiPart(parts) => parts
|
MessageContent::MultiPart(parts) => parts
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|part| convert_assistant_part(part, encode_images_as_base64))
|
.map(|part| convert_assistant_part(part, encode_images_as_base64))
|
||||||
@@ -147,7 +154,8 @@ fn convert_user_part(
|
|||||||
tool_use_id,
|
tool_use_id,
|
||||||
content,
|
content,
|
||||||
is_error,
|
is_error,
|
||||||
} => Ok(UserContent::tool_result(
|
} => Ok(UserContent::tool_result_with_call_id(
|
||||||
|
tool_use_id.clone(),
|
||||||
tool_use_id,
|
tool_use_id,
|
||||||
OneOrMany::one(ToolResultContent::text(tool_result_text(content, is_error))),
|
OneOrMany::one(ToolResultContent::text(tool_result_text(content, is_error))),
|
||||||
)),
|
)),
|
||||||
@@ -178,7 +186,12 @@ fn convert_assistant_part(
|
|||||||
tool_use_id,
|
tool_use_id,
|
||||||
name,
|
name,
|
||||||
input,
|
input,
|
||||||
} => Ok(AssistantContent::tool_call(tool_use_id, name, input)),
|
} => Ok(AssistantContent::tool_call_with_call_id(
|
||||||
|
tool_use_id.clone(),
|
||||||
|
tool_use_id,
|
||||||
|
name,
|
||||||
|
input,
|
||||||
|
)),
|
||||||
ContentPart::ToolResult { .. } => Err(invalid_role("tool result", "assistant")),
|
ContentPart::ToolResult { .. } => Err(invalid_role("tool result", "assistant")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,11 +103,7 @@ where
|
|||||||
Ok(StreamedAssistantContent::ToolCall { tool_call, .. }) => {
|
Ok(StreamedAssistantContent::ToolCall { tool_call, .. }) => {
|
||||||
yield Ok(AgentEvent::Tool {
|
yield Ok(AgentEvent::Tool {
|
||||||
event: galaxy_agent_core::ToolEvent::Proposed {
|
event: galaxy_agent_core::ToolEvent::Proposed {
|
||||||
call: ToolCall {
|
call: domain_tool_call(tool_call),
|
||||||
id: tool_call.id,
|
|
||||||
name: tool_call.function.name,
|
|
||||||
arguments: tool_call.function.arguments,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -146,6 +142,18 @@ where
|
|||||||
Ok(Box::pin(events))
|
Ok(Box::pin(events))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn domain_tool_call(tool_call: rig_core::message::ToolCall) -> ToolCall {
|
||||||
|
ToolCall {
|
||||||
|
// OpenAI Responses uses a separate `call_id` for function-call output
|
||||||
|
// correlation. The domain model has one ID, so preserve that value when
|
||||||
|
// it is available and fall back to the standard tool-call ID for other
|
||||||
|
// OpenAI-compatible providers.
|
||||||
|
id: tool_call.call_id.unwrap_or(tool_call.id),
|
||||||
|
name: tool_call.function.name,
|
||||||
|
arguments: tool_call.function.arguments,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn stopped_before_stream(runtime_request_id: String) -> AgentEventStream {
|
fn stopped_before_stream(runtime_request_id: String) -> AgentEventStream {
|
||||||
Box::pin(futures::stream::iter([
|
Box::pin(futures::stream::iter([
|
||||||
Ok(AgentEvent::TurnStarted { runtime_request_id }),
|
Ok(AgentEvent::TurnStarted { runtime_request_id }),
|
||||||
@@ -206,3 +214,25 @@ fn map_completion_error(error: CompletionError) -> AgentError {
|
|||||||
);
|
);
|
||||||
mapped
|
mapped
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::domain_tool_call;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn domain_tool_call_prefers_responses_call_id() {
|
||||||
|
let tool_call = rig_core::message::ToolCall::new(
|
||||||
|
"fc_item_123".to_string(),
|
||||||
|
rig_core::message::ToolFunction {
|
||||||
|
name: "read_files".to_string(),
|
||||||
|
arguments: serde_json::json!({"files": ["Cargo.toml"]}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.with_call_id("call_123".to_string());
|
||||||
|
|
||||||
|
let call = domain_tool_call(tool_call);
|
||||||
|
|
||||||
|
assert_eq!(call.id, "call_123");
|
||||||
|
assert_eq!(call.name, "read_files");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,3 +22,13 @@ fn only_oss_disables_warp_service_egress() {
|
|||||||
assert!(Channel::Integration.allows_warp_service_egress());
|
assert!(Channel::Integration.allows_warp_service_egress());
|
||||||
assert!(!Channel::Oss.allows_warp_service_egress());
|
assert!(!Channel::Oss.allows_warp_service_egress());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_oss_is_local_first() {
|
||||||
|
assert!(!Channel::Stable.is_local_first());
|
||||||
|
assert!(!Channel::Preview.is_local_first());
|
||||||
|
assert!(!Channel::Dev.is_local_first());
|
||||||
|
assert!(!Channel::Local.is_local_first());
|
||||||
|
assert!(!Channel::Integration.is_local_first());
|
||||||
|
assert!(Channel::Oss.is_local_first());
|
||||||
|
}
|
||||||
|
|||||||
@@ -63,6 +63,12 @@ impl Channel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the channel is local-first and must not expose Warp-owned
|
||||||
|
/// identity, sharing, or workspace state to the product surface.
|
||||||
|
pub fn is_local_first(&self) -> bool {
|
||||||
|
!self.allows_warp_service_egress()
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns the CLI command name corresponding to this channel.
|
/// Returns the CLI command name corresponding to this channel.
|
||||||
pub fn cli_command_name(&self) -> &'static str {
|
pub fn cli_command_name(&self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
|
|||||||
@@ -386,13 +386,31 @@ metadata.
|
|||||||
restoration to the local service without account or online-state requirements.
|
restoration to the local service without account or online-state requirements.
|
||||||
- [x] Move environment-variable collection create, edit, duplicate, trash, restore, delete, and
|
- [x] Move environment-variable collection create, edit, duplicate, trash, restore, delete, and
|
||||||
local pane loading to the local service.
|
local pane loading to the local service.
|
||||||
- [ ] Move MCP configs to the local service.
|
- [x] Move templatable MCP config create, edit, delete, local ownership checks, and SQLite-backed
|
||||||
- [ ] Replace remaining account/workspace ownership with local scopes.
|
persistence to the local service while keeping process lifecycle and credentials separate.
|
||||||
|
- [x] Make the OSS channel expose only the local Personal scope and resolve it to the stable local
|
||||||
|
owner; remote-capable channels retain their existing workspace/shared-space behavior.
|
||||||
|
- [ ] Replace remaining account/workspace ownership in kept content flows with local scopes.
|
||||||
- [ ] Remove auth, teams, billing, referral, cloud sync, GraphQL, RTC, sharing, and remote-control UI.
|
- [ ] Remove auth, teams, billing, referral, cloud sync, GraphQL, RTC, sharing, and remote-control UI.
|
||||||
|
|
||||||
Exit condition: none of the kept content flows require `AuthState`, `CloudModel`, `UpdateManager`,
|
Exit condition: none of the kept content flows require `AuthState`, `CloudModel`, `UpdateManager`,
|
||||||
`SyncQueue`, or a server ID.
|
`SyncQueue`, or a server ID.
|
||||||
|
|
||||||
|
## Noticed bugs and TLC backlog
|
||||||
|
|
||||||
|
This list tracks bugs and rough edges noticed while completing the migration phases. Items should be
|
||||||
|
assigned to the phase that owns the affected flow before the related work is considered finished.
|
||||||
|
|
||||||
|
- [ ] Long-Running command monitor: give the monitor state machine and UI a focused pass. Audit
|
||||||
|
command start/stop/completion transitions, stale monitor state after cancellation or restart,
|
||||||
|
output refresh and scrolling, failure/timeout handling, and restore behavior. Add deterministic
|
||||||
|
unit coverage and a hermetic integration flow for a command that remains active while the agent
|
||||||
|
continues running.
|
||||||
|
- [ ] Open-source project presentation: structure the About page around Galaxy’s local-first
|
||||||
|
identity, audit the repository’s license and third-party notices, and make the root metadata,
|
||||||
|
contribution guidance, and license files agree on the final permissive license (currently the
|
||||||
|
repository contains both MIT and AGPL materials and needs an explicit policy).
|
||||||
|
|
||||||
### Phase 7 — UI untangling
|
### Phase 7 — UI untangling
|
||||||
|
|
||||||
- Split coordinator files along the flow boundaries in the UI ledger.
|
- Split coordinator files along the flow boundaries in the UI ledger.
|
||||||
@@ -446,7 +464,8 @@ contract is what the UI and persistence observe.
|
|||||||
|
|
||||||
## Immediate next vertical slice
|
## Immediate next vertical slice
|
||||||
|
|
||||||
Continue Phase 6 by moving MCP configs behind `LocalObjectRepository`, preserving existing SQLite
|
Continue Phase 6 by replacing remaining account/workspace ownership with local scopes and removing
|
||||||
rows and pane/session restore behavior while removing account ownership and online-state gates from
|
cloud identity UI from kept flows. The next bounded implementation is to move the remaining Galaxy
|
||||||
their kept flows. Live Phase 4 Bedrock semantic comparisons remain an explicit opt-in validation task
|
Drive folder mutations (create, rename, trash, and open-state persistence) behind
|
||||||
because they require configured AWS access.
|
`LocalObjectRepository` for OSS. Live Phase 4 Bedrock semantic comparisons remain an explicit
|
||||||
|
opt-in validation task because they require configured AWS access.
|
||||||
|
|||||||
Reference in New Issue
Block a user