Complete local-first Rig provider migration

This commit is contained in:
2026-08-06 11:37:28 -05:00
parent f850bae77c
commit 634ce7ba00
38 changed files with 3837 additions and 1616 deletions
Generated
+34 -8
View File
@@ -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
View File
@@ -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"
+1
View File
@@ -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"
+6 -11
View File
@@ -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))
} }
+193
View File
@@ -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",
));
}
}
+1
View File
@@ -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;
-149
View File
@@ -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],
-21
View File
@@ -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,
+101
View File
@@ -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 {}
+27 -1
View File
@@ -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
View File
@@ -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();
+56
View File
@@ -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");
+51 -2
View File
@@ -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())
+4
View File
@@ -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
View File
@@ -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
View File
@@ -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(
+20 -2
View File
@@ -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");
+84 -1
View File
@@ -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(
+70
View File
@@ -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);
+58
View File
@@ -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
View File
@@ -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 {
+63 -1
View File
@@ -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]
File diff suppressed because it is too large Load Diff
+1
View File
@@ -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
+50 -38
View File
@@ -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(),
+28 -1
View File
@@ -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);
+10 -1
View File
@@ -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."
) )
} }
+219
View File
@@ -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" }
}))
);
}
}
+2
View File
@@ -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");
}; };
+17 -4
View File
@@ -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")),
} }
} }
+35 -5
View File
@@ -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());
}
+6
View File
@@ -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 {
+25 -6
View File
@@ -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 Galaxys local-first
identity, audit the repositorys 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.