Complete local-first Rig provider migration
This commit is contained in:
@@ -86,17 +86,12 @@ impl AcpRuntimeModel {
|
||||
Ok(manager)
|
||||
}
|
||||
|
||||
pub(crate) fn discovery_config(settings: &AISettings) -> Result<AcpManagerConfig, String> {
|
||||
let agent_id = if settings.acp_agent_id.value().trim().is_empty() {
|
||||
"codex"
|
||||
} else {
|
||||
settings.acp_agent_id.value().trim()
|
||||
};
|
||||
let launch = crate::ai::acp::resolve_acp_launch(
|
||||
agent_id,
|
||||
settings.acp_agent_command.value(),
|
||||
settings.acp_agent_args.value(),
|
||||
)?;
|
||||
pub(crate) fn discovery_config_for_values(
|
||||
agent_id: &str,
|
||||
command: &str,
|
||||
args: &[String],
|
||||
) -> Result<AcpManagerConfig, String> {
|
||||
let launch = crate::ai::acp::resolve_acp_launch(agent_id, command, args)?;
|
||||
Ok(AcpManagerConfig::new(launch))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
//! AWS Bedrock control-plane discovery.
|
||||
//!
|
||||
//! The foundation-model catalog is only a candidate list. Every candidate is
|
||||
//! checked with `GetFoundationModelAvailability` before it is offered to the
|
||||
//! user or persisted in Galaxy settings.
|
||||
|
||||
use aws_config::BehaviorVersion;
|
||||
use aws_sdk_bedrock::Client;
|
||||
use aws_sdk_bedrockruntime::config::Region;
|
||||
|
||||
use super::client::{BedrockClientConfig, BedrockError};
|
||||
use crate::settings::ai::BedrockModelConfig;
|
||||
|
||||
pub async fn discover_available_models(
|
||||
config: BedrockClientConfig,
|
||||
) -> Result<Vec<BedrockModelConfig>, String> {
|
||||
let aws_config = load_aws_config(&config)
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
let client = Client::new(&aws_config);
|
||||
let catalog = client
|
||||
.list_foundation_models()
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| format!("Could not list AWS Bedrock foundation models: {error}"))?;
|
||||
|
||||
let mut models = Vec::new();
|
||||
for summary in catalog.model_summaries() {
|
||||
let model_id = summary.model_id();
|
||||
let availability = match client
|
||||
.get_foundation_model_availability()
|
||||
.model_id(model_id)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(availability) => availability,
|
||||
Err(error) => {
|
||||
log::debug!(
|
||||
"[bedrock] Availability check failed for {model_id}; excluding model: {error}"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if !model_availability_is_usable(
|
||||
availability
|
||||
.agreement_availability()
|
||||
.map(|agreement| agreement.status().as_str()),
|
||||
availability.authorization_status().as_str(),
|
||||
availability.entitlement_availability().as_str(),
|
||||
availability.region_availability().as_str(),
|
||||
) {
|
||||
log::debug!(
|
||||
"[bedrock] Excluding {model_id}: agreement={}, authorization={}, entitlement={}, region={}",
|
||||
availability
|
||||
.agreement_availability()
|
||||
.map(|agreement| agreement.status().as_str())
|
||||
.unwrap_or("MISSING"),
|
||||
availability.authorization_status().as_str(),
|
||||
availability.entitlement_availability().as_str(),
|
||||
availability.region_availability().as_str(),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let display_name = summary
|
||||
.model_name()
|
||||
.map(str::to_owned)
|
||||
.unwrap_or_else(|| prettify_model_id(model_id));
|
||||
let vision_supported = summary
|
||||
.input_modalities()
|
||||
.iter()
|
||||
.any(|modality| modality.as_str() == "IMAGE");
|
||||
|
||||
models.push(BedrockModelConfig {
|
||||
model_id: model_id.to_owned(),
|
||||
display_name,
|
||||
vision_supported,
|
||||
use_rig: false,
|
||||
});
|
||||
}
|
||||
|
||||
models.sort_by(|left, right| left.display_name.cmp(&right.display_name));
|
||||
if models.is_empty() {
|
||||
return Err(
|
||||
"AWS returned no Bedrock models that are authorized and available in this region."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
Ok(models)
|
||||
}
|
||||
|
||||
fn model_availability_is_usable(
|
||||
agreement_status: Option<&str>,
|
||||
authorization_status: &str,
|
||||
entitlement_status: &str,
|
||||
region_status: &str,
|
||||
) -> bool {
|
||||
agreement_status == Some("AVAILABLE")
|
||||
&& authorization_status == "AUTHORIZED"
|
||||
&& entitlement_status == "AVAILABLE"
|
||||
&& region_status == "AVAILABLE"
|
||||
}
|
||||
|
||||
async fn load_aws_config(
|
||||
config: &BedrockClientConfig,
|
||||
) -> Result<aws_config::SdkConfig, BedrockError> {
|
||||
let sdk_config = match config.auth_method {
|
||||
crate::settings::ai::BedrockAuthMethod::Profile
|
||||
| crate::settings::ai::BedrockAuthMethod::Sso => {
|
||||
let mut loader = aws_config::defaults(BehaviorVersion::latest());
|
||||
if !config.profile.is_empty() && config.profile != "default" {
|
||||
loader = loader.profile_name(&config.profile);
|
||||
}
|
||||
if !config.region.is_empty() {
|
||||
loader = loader.region(Region::new(config.region.clone()));
|
||||
}
|
||||
loader.load().await
|
||||
}
|
||||
crate::settings::ai::BedrockAuthMethod::StaticKeys => {
|
||||
if config.access_key_id.is_empty() || config.secret_access_key.is_empty() {
|
||||
return Err(BedrockError::CredentialsNotConfigured);
|
||||
}
|
||||
let credentials = aws_credential_types::Credentials::new(
|
||||
&config.access_key_id,
|
||||
&config.secret_access_key,
|
||||
config.session_token.clone(),
|
||||
None,
|
||||
"galaxy-bedrock-discovery",
|
||||
);
|
||||
let mut loader =
|
||||
aws_config::defaults(BehaviorVersion::latest()).credentials_provider(credentials);
|
||||
loader = loader.region(Region::new(if config.region.is_empty() {
|
||||
"us-east-1".to_string()
|
||||
} else {
|
||||
config.region.clone()
|
||||
}));
|
||||
loader.load().await
|
||||
}
|
||||
};
|
||||
|
||||
if sdk_config.region().is_none() {
|
||||
return Err(BedrockError::RegionNotConfigured);
|
||||
}
|
||||
Ok(sdk_config)
|
||||
}
|
||||
|
||||
fn prettify_model_id(model_id: &str) -> String {
|
||||
model_id
|
||||
.rsplit('.')
|
||||
.next()
|
||||
.unwrap_or(model_id)
|
||||
.replace(['-', ':'], " ")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::model_availability_is_usable;
|
||||
|
||||
#[test]
|
||||
fn requires_every_availability_status() {
|
||||
assert!(model_availability_is_usable(
|
||||
Some("AVAILABLE"),
|
||||
"AUTHORIZED",
|
||||
"AVAILABLE",
|
||||
"AVAILABLE",
|
||||
));
|
||||
assert!(!model_availability_is_usable(
|
||||
None,
|
||||
"AUTHORIZED",
|
||||
"AVAILABLE",
|
||||
"AVAILABLE",
|
||||
));
|
||||
assert!(!model_availability_is_usable(
|
||||
Some("AVAILABLE"),
|
||||
"NOT_AUTHORIZED",
|
||||
"AVAILABLE",
|
||||
"AVAILABLE",
|
||||
));
|
||||
assert!(!model_availability_is_usable(
|
||||
Some("AVAILABLE"),
|
||||
"AUTHORIZED",
|
||||
"NOT_AVAILABLE",
|
||||
"AVAILABLE",
|
||||
));
|
||||
assert!(!model_availability_is_usable(
|
||||
Some("AVAILABLE"),
|
||||
"AUTHORIZED",
|
||||
"AVAILABLE",
|
||||
"NOT_AVAILABLE",
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ pub mod client;
|
||||
pub mod convert;
|
||||
pub mod crash_log;
|
||||
pub mod diagnostic;
|
||||
pub mod discovery;
|
||||
pub mod external_config;
|
||||
pub mod models;
|
||||
pub mod request_translator;
|
||||
|
||||
@@ -1,156 +1,7 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use super::external_config::ExternalBedrockConfig;
|
||||
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(
|
||||
selected_model_id: &str,
|
||||
configured_models: &[BedrockModelConfig],
|
||||
|
||||
@@ -78,27 +78,6 @@ fn test_cross_region_prefix_unknown_region() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_effective_models_empty_returns_defaults() {
|
||||
let models = get_effective_models(&[]);
|
||||
assert_eq!(models.len(), DEFAULT_BEDROCK_MODELS.len());
|
||||
assert_eq!(models[0].model_id, "us.anthropic.claude-opus-4-6-v1[1m]");
|
||||
assert_eq!(models[0].display_name, "Claude Opus 4.6 (1M)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_effective_models_custom_overrides() {
|
||||
let custom = vec![BedrockModelConfig {
|
||||
model_id: "custom.model-v1:0".to_string(),
|
||||
display_name: "Custom Model".to_string(),
|
||||
vision_supported: false,
|
||||
use_rig: true,
|
||||
}];
|
||||
let models = get_effective_models(&custom);
|
||||
assert_eq!(models.len(), 1);
|
||||
assert_eq!(models[0].model_id, "custom.model-v1:0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cross_region_prefix_skips_arn() {
|
||||
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);
|
||||
if let Some(client_config) = llm_prefs.openai_client_config_for_model(model_id) {
|
||||
return ProviderConfig::OpenAI(OpenAIClientConfig {
|
||||
kind: client_config.kind,
|
||||
base_url: client_config.base_url.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_output_tokens: client_config.max_output_tokens,
|
||||
use_rig: client_config.use_rig,
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
//! ChatGPT subscription OAuth state used by the AI settings page.
|
||||
|
||||
use async_channel::unbounded;
|
||||
use galaxy_agent_rig::{ChatGPTDeviceCode, ChatGPTSubscriptionClient};
|
||||
use galaxyui::{Entity, ModelContext, SingletonEntity};
|
||||
|
||||
/// Current state of the local ChatGPT subscription connection.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum ChatGPTAuthState {
|
||||
NotConnected,
|
||||
Connecting,
|
||||
AwaitingDeviceCode {
|
||||
verification_uri: String,
|
||||
user_code: String,
|
||||
},
|
||||
Connected,
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
enum ChatGPTAuthEvent {
|
||||
DeviceCode(ChatGPTDeviceCode),
|
||||
Completed(Result<(), String>),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) enum ChatGPTAuthModelEvent {
|
||||
StateChanged,
|
||||
}
|
||||
|
||||
/// Coordinates Rig's device authorization flow with Galaxy UI.
|
||||
pub(crate) struct ChatGPTAuthModel {
|
||||
state: ChatGPTAuthState,
|
||||
}
|
||||
|
||||
impl ChatGPTAuthModel {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self {
|
||||
state: ChatGPTAuthState::NotConnected,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn state(&self) -> &ChatGPTAuthState {
|
||||
&self.state
|
||||
}
|
||||
|
||||
pub(crate) fn connect(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
if matches!(
|
||||
self.state,
|
||||
ChatGPTAuthState::Connecting | ChatGPTAuthState::AwaitingDeviceCode { .. }
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
self.state = ChatGPTAuthState::Connecting;
|
||||
ctx.emit(ChatGPTAuthModelEvent::StateChanged);
|
||||
|
||||
let (event_tx, event_rx) = unbounded();
|
||||
let device_code_tx = event_tx.clone();
|
||||
let _ = ctx.spawn_stream_local(
|
||||
event_rx,
|
||||
|model, event, ctx| {
|
||||
match event {
|
||||
ChatGPTAuthEvent::DeviceCode(code) => {
|
||||
model.state = ChatGPTAuthState::AwaitingDeviceCode {
|
||||
verification_uri: code.verification_uri,
|
||||
user_code: code.user_code,
|
||||
};
|
||||
}
|
||||
ChatGPTAuthEvent::Completed(result) => {
|
||||
model.state = match result {
|
||||
Ok(()) => ChatGPTAuthState::Connected,
|
||||
Err(error) => ChatGPTAuthState::Failed(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
ctx.emit(ChatGPTAuthModelEvent::StateChanged);
|
||||
},
|
||||
|_, _| {},
|
||||
);
|
||||
|
||||
let _ = ctx.spawn(
|
||||
async move {
|
||||
let result =
|
||||
match ChatGPTSubscriptionClient::with_device_code_handler(move |code| {
|
||||
let _ = device_code_tx.try_send(ChatGPTAuthEvent::DeviceCode(code));
|
||||
}) {
|
||||
Ok(client) => client.authorize().await,
|
||||
Err(error) => Err(error),
|
||||
};
|
||||
let _ = event_tx.send(ChatGPTAuthEvent::Completed(result)).await;
|
||||
},
|
||||
|_, _, _| {},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for ChatGPTAuthModel {
|
||||
type Event = ChatGPTAuthModelEvent;
|
||||
}
|
||||
|
||||
impl SingletonEntity for ChatGPTAuthModel {}
|
||||
@@ -14,6 +14,7 @@ use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::llms::LLMPreferences;
|
||||
use crate::ai::openai::client::{OpenAIClient, OpenAIClientConfig};
|
||||
use crate::ai::provider::ProviderConfig;
|
||||
use crate::settings::OpenAIProviderKind;
|
||||
use crate::AISettings;
|
||||
|
||||
/// Maximum default iterations if the setting is somehow zero.
|
||||
@@ -163,9 +164,14 @@ impl CrosscheckReviewer {
|
||||
let llm_prefs = LLMPreferences::as_ref(ctx);
|
||||
if let Some(client_config) = llm_prefs.openai_client_config_for_model(model_id) {
|
||||
return ProviderConfig::OpenAI(OpenAIClientConfig {
|
||||
kind: client_config.kind,
|
||||
base_url: client_config.base_url.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_output_tokens: Some(REVIEWER_MAX_OUTPUT_TOKENS),
|
||||
use_rig: client_config.use_rig,
|
||||
@@ -217,6 +223,26 @@ impl CrosscheckReviewer {
|
||||
provider_config: ProviderConfig,
|
||||
) -> Result<String, String> {
|
||||
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) => {
|
||||
Self::invoke_via_openai(agent_output, model_id, config).await
|
||||
}
|
||||
|
||||
+237
-79
@@ -17,14 +17,16 @@ use warp_multi_agent_api as api;
|
||||
use super::custom_model_routers::{self, CustomModelRouter, ModelConfigError};
|
||||
use super::execution_profiles::profiles::AIExecutionProfilesModel;
|
||||
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::AuthStateProvider;
|
||||
use crate::network::{NetworkStatus, NetworkStatusEvent, NetworkStatusKind};
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use crate::persistence::model::{AcpConversationData, AgentBackend};
|
||||
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::workspaces::user_workspaces::{UserWorkspaces, UserWorkspacesEvent};
|
||||
use crate::{report_error, AISettings};
|
||||
@@ -659,6 +661,7 @@ impl LLMPreferences {
|
||||
| AISettingsChangedEvent::OpenAIProviders { .. }
|
||||
| AISettingsChangedEvent::AcpAgents { .. }
|
||||
| AISettingsChangedEvent::AcpAgentId { .. }
|
||||
| AISettingsChangedEvent::BedrockModels { .. }
|
||||
) {
|
||||
me.inject_bedrock_models(ctx);
|
||||
me.inject_openai_models(ctx);
|
||||
@@ -670,6 +673,11 @@ impl LLMPreferences {
|
||||
) {
|
||||
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.
|
||||
// If all provider models were removed, the default_id would dangle.
|
||||
me.ensure_default_model_present();
|
||||
@@ -709,8 +717,8 @@ impl LLMPreferences {
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
{
|
||||
Self::ensure_default_models_in_settings(ctx);
|
||||
me.inject_bedrock_models(ctx);
|
||||
Self::ensure_default_chatgpt_models_in_settings(ctx);
|
||||
me.refresh_bedrock_models(ctx);
|
||||
me.inject_openai_models(ctx);
|
||||
me.ensure_default_model_present();
|
||||
me.fetch_openai_models_from_endpoint(ctx);
|
||||
@@ -720,39 +728,85 @@ impl LLMPreferences {
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
fn ensure_default_models_in_settings(ctx: &mut ModelContext<Self>) {
|
||||
use crate::ai::bedrock::models::DEFAULT_BEDROCK_MODELS;
|
||||
fn ensure_default_chatgpt_models_in_settings(ctx: &mut ModelContext<Self>) {
|
||||
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);
|
||||
let mut current_models: Vec<BedrockModelConfig> = settings.bedrock_models.value().clone();
|
||||
for default_model in &default_chatgpt_models {
|
||||
if !provider
|
||||
.models
|
||||
.iter()
|
||||
.any(|model| model.model_id == default_model.model_id)
|
||||
{
|
||||
provider.models.push(default_model.clone());
|
||||
providers_changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
let existing_ids: std::collections::HashSet<String> =
|
||||
current_models.iter().map(|m| m.model_id.clone()).collect();
|
||||
|
||||
let mut added = false;
|
||||
for default in DEFAULT_BEDROCK_MODELS {
|
||||
if !existing_ids.contains(default.model_id as &str) {
|
||||
current_models.push(BedrockModelConfig {
|
||||
model_id: default.model_id.to_string(),
|
||||
display_name: default.display_name.to_string(),
|
||||
vision_supported: default.vision_supported,
|
||||
use_rig: false,
|
||||
});
|
||||
added = true;
|
||||
for model in &mut provider.models {
|
||||
if !model.reasoning_efforts.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some(default_model) = default_chatgpt_models
|
||||
.iter()
|
||||
.find(|default_model| default_model.model_id == model.model_id)
|
||||
{
|
||||
if !default_model.reasoning_efforts.is_empty() {
|
||||
model.reasoning_efforts = default_model.reasoning_efforts.clone();
|
||||
providers_changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if added {
|
||||
log::info!(
|
||||
"[bedrock] Added missing default models to settings — now {} total",
|
||||
current_models.len()
|
||||
);
|
||||
if providers_changed {
|
||||
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"))]
|
||||
fn inject_bedrock_models(&mut self, ctx: &AppContext) {
|
||||
// Galaxy's runtime inventory is rebuilt exclusively from enabled local
|
||||
@@ -768,7 +822,9 @@ impl LLMPreferences {
|
||||
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 cross_region = *settings.bedrock_cross_region_inference.value();
|
||||
|
||||
@@ -777,7 +833,7 @@ impl LLMPreferences {
|
||||
let external_config = ExternalBedrockConfig::load();
|
||||
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
|
||||
if require_1h_cache {
|
||||
@@ -943,8 +999,15 @@ impl LLMPreferences {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut provider_entries: Vec<(String, String, Option<String>, Vec<OpenAIModelConfig>)> =
|
||||
Vec::new();
|
||||
type OpenAIProviderEntry = (
|
||||
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 single_provider_models = if configured_models.is_empty() {
|
||||
@@ -968,7 +1031,14 @@ impl LLMPreferences {
|
||||
} else {
|
||||
"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(
|
||||
@@ -977,11 +1047,17 @@ impl LLMPreferences {
|
||||
.value()
|
||||
.iter()
|
||||
.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;
|
||||
}
|
||||
Some((
|
||||
provider.name.clone(),
|
||||
provider.kind,
|
||||
provider.enabled,
|
||||
provider.base_url.clone(),
|
||||
provider.api_key.clone(),
|
||||
provider.models.clone(),
|
||||
@@ -995,58 +1071,93 @@ impl LLMPreferences {
|
||||
|
||||
let mut total_injected = 0;
|
||||
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 {
|
||||
if !model.enabled {
|
||||
continue;
|
||||
}
|
||||
if !seen_model_ids.insert(model.model_id.clone()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Register the routing entry
|
||||
let client_config = OpenAIClientConfig {
|
||||
base_url: base_url.clone(),
|
||||
api_key: api_key.clone(),
|
||||
model: None, // filled per-request from model_id
|
||||
max_input_tokens: Some(openai_model_context_size(model)),
|
||||
max_output_tokens: model.max_output_tokens,
|
||||
use_rig: model.use_rig,
|
||||
supports_system_messages: model.supports_system_messages(),
|
||||
};
|
||||
self.openai_provider_routing
|
||||
.insert(model.model_id.clone(), client_config);
|
||||
let reasoning_efforts: Vec<Option<&String>> =
|
||||
if provider_kind == OpenAIProviderKind::ChatGPTSubscription {
|
||||
// Keep the base model as the provider-default mode, then expose each
|
||||
// explicitly supported effort as a separate selectable variant.
|
||||
std::iter::once(None)
|
||||
.chain(model.reasoning_efforts.iter().map(Some))
|
||||
.collect()
|
||||
} else {
|
||||
vec![None]
|
||||
};
|
||||
|
||||
let llm_info = LLMInfo {
|
||||
id: LLMId::from(model.model_id.as_str()),
|
||||
display_name: model.display_name.clone(),
|
||||
base_model_name: model.display_name.clone(),
|
||||
reasoning_level: None,
|
||||
usage_metadata: LLMUsageMetadata {
|
||||
request_multiplier: 1,
|
||||
credit_multiplier: None,
|
||||
},
|
||||
description: Some(provider_name.clone()),
|
||||
disable_reason: None,
|
||||
vision_supported: model.vision_supported,
|
||||
spec: None,
|
||||
provider: LLMProvider::LiteLLM,
|
||||
host_configs: HashMap::from([(
|
||||
LLMModelHost::DirectApi,
|
||||
RoutingHostConfig {
|
||||
enabled: true,
|
||||
model_routing_host: LLMModelHost::DirectApi,
|
||||
for reasoning_effort in reasoning_efforts {
|
||||
let reasoning_effort = reasoning_effort.cloned();
|
||||
let model_key = reasoning_effort.as_deref().map_or_else(
|
||||
|| model.model_id.clone(),
|
||||
|effort| openai_model_variant_id(&model.model_id, effort),
|
||||
);
|
||||
|
||||
// Register the routing entry. Reasoning variants keep the provider's
|
||||
// actual model ID while using their synthetic key only for selection.
|
||||
let client_config = OpenAIClientConfig {
|
||||
kind: provider_kind,
|
||||
base_url: base_url.clone(),
|
||||
api_key: api_key.clone(),
|
||||
model: Some(model.model_id.clone()),
|
||||
reasoning_effort: reasoning_effort.clone(),
|
||||
max_input_tokens: Some(openai_model_context_size(model)),
|
||||
max_output_tokens: model.max_output_tokens,
|
||||
use_rig: model.use_rig
|
||||
|| 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,
|
||||
},
|
||||
)]),
|
||||
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);
|
||||
description: Some(provider_name.clone()),
|
||||
disable_reason: None,
|
||||
vision_supported: model.vision_supported,
|
||||
spec: None,
|
||||
provider: LLMProvider::LiteLLM,
|
||||
host_configs: HashMap::from([(
|
||||
LLMModelHost::DirectApi,
|
||||
RoutingHostConfig {
|
||||
enabled: true,
|
||||
model_routing_host: LLMModelHost::DirectApi,
|
||||
},
|
||||
)]),
|
||||
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.
|
||||
pub fn get_active_base_model<'a>(
|
||||
&'a self,
|
||||
@@ -2228,7 +2376,7 @@ fn openai_model_context_size(model: &OpenAIModelConfig) -> u32 {
|
||||
/// Merges endpoint metadata into a provider's configured models without
|
||||
/// discarding local routing choices or manually configured models.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
fn merge_discovered_provider_models(
|
||||
pub(crate) fn merge_discovered_provider_models(
|
||||
existing_models: &[OpenAIModelConfig],
|
||||
discovered_models: Vec<OpenAIModelConfig>,
|
||||
) -> Vec<OpenAIModelConfig> {
|
||||
@@ -2245,6 +2393,7 @@ fn merge_discovered_provider_models(
|
||||
.find(|model| model.model_id == discovered.model_id)
|
||||
{
|
||||
discovered.display_name = existing.display_name.clone();
|
||||
discovered.enabled = existing.enabled;
|
||||
discovered.use_rig = existing.use_rig;
|
||||
if existing.supports_system_messages.is_some() {
|
||||
discovered.supports_system_messages = existing.supports_system_messages;
|
||||
@@ -2271,6 +2420,11 @@ fn merge_discovered_provider_models(
|
||||
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"))]
|
||||
fn openai_model_context_window(model: &OpenAIModelConfig) -> LLMContextWindow {
|
||||
let context_size = openai_model_context_size(model);
|
||||
@@ -2400,6 +2554,8 @@ async fn fetch_from_litellm_model_info(
|
||||
} else {
|
||||
model_info["supports_system_messages"].as_bool()
|
||||
},
|
||||
reasoning_efforts: Vec::new(),
|
||||
enabled: true,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -2527,6 +2683,8 @@ async fn fetch_from_openai_models(
|
||||
} else {
|
||||
m["supports_system_messages"].as_bool()
|
||||
},
|
||||
reasoning_efforts: Vec::new(),
|
||||
enabled: true,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -152,6 +152,8 @@ fn openai_model(model_id: &str) -> OpenAIModelConfig {
|
||||
provider: None,
|
||||
use_rig: false,
|
||||
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]
|
||||
fn provider_discovery_enables_rig_for_new_models_and_keeps_manual_models() {
|
||||
let manual = openai_model("manual-model");
|
||||
|
||||
@@ -41,6 +41,7 @@ use crate::cloud_object::{
|
||||
CloudObjectUuidLookup as _, GenericStringObjectFormat, JsonObjectType, Space,
|
||||
};
|
||||
use crate::drive::CloudObjectTypeAndId;
|
||||
use crate::local_object_repository::{local_owner, LocalObjectRepository};
|
||||
use crate::persistence::{
|
||||
database_file_path_for_scope, establish_ro_connection, ModelEvent, PersistenceScope,
|
||||
};
|
||||
@@ -499,6 +500,19 @@ impl TemplatableMCPServerManager {
|
||||
initiated_by: InitiatedBy,
|
||||
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);
|
||||
if let Some(owner) = owner {
|
||||
let update_manager = UpdateManager::handle(ctx);
|
||||
@@ -527,9 +541,24 @@ impl TemplatableMCPServerManager {
|
||||
template_server: TemplatableMCPServer,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let cloud_templatable_mcp_server =
|
||||
self.get_cloud_templatable_mcp_server(template_server.uuid);
|
||||
let cloud_templatable_mcp_server = self
|
||||
.get_cloud_templatable_mcp_server(template_server.uuid)
|
||||
.cloned();
|
||||
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);
|
||||
update_manager.update(ctx, |update_manager, ctx| {
|
||||
update_manager.update_templatable_mcp_server(
|
||||
@@ -553,6 +582,16 @@ impl TemplatableMCPServerManager {
|
||||
|
||||
let cloud_templatable_mcp_server = self.get_cloud_templatable_mcp_server(uuid);
|
||||
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 {
|
||||
object_type: GenericStringObjectFormat::Json(JsonObjectType::TemplatableMCPServer),
|
||||
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);
|
||||
|
||||
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 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 {
|
||||
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 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();
|
||||
cloud_templatable_mcp_server.metadata().creator_uid
|
||||
== auth_state.user_id().map(|user_id| user_id.as_string())
|
||||
|
||||
@@ -24,6 +24,8 @@ pub mod bedrock;
|
||||
pub(crate) mod bedrock_credentials;
|
||||
pub(crate) mod block_context;
|
||||
pub(crate) mod blocklist;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub(crate) mod chatgpt_auth;
|
||||
#[cfg(any(feature = "local_fs", not(target_family = "wasm")))]
|
||||
pub(crate) mod codebase_auto_indexing;
|
||||
pub mod control_code_parser;
|
||||
@@ -80,6 +82,8 @@ pub(crate) use ai::paths;
|
||||
pub fn init(app: &mut AppContext) {
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
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::block::number_shortcut_buttons::init(app);
|
||||
blocklist::toggleable_items::init(app);
|
||||
|
||||
@@ -4,11 +4,15 @@ use bytes::Bytes;
|
||||
use futures::Stream;
|
||||
use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE};
|
||||
|
||||
use crate::settings::OpenAIProviderKind;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct OpenAIClientConfig {
|
||||
pub kind: OpenAIProviderKind,
|
||||
pub base_url: String,
|
||||
pub api_key: Option<String>,
|
||||
pub model: Option<String>,
|
||||
pub reasoning_effort: Option<String>,
|
||||
pub max_input_tokens: Option<u32>,
|
||||
pub max_output_tokens: Option<u32>,
|
||||
pub use_rig: bool,
|
||||
|
||||
+40
-16
@@ -7,7 +7,10 @@ use galaxy_agent_core::{
|
||||
turn_control, AgentError, AgentEvent, AgentRuntime, MessageContent, MessageRole, ToolCall,
|
||||
ToolCallDecision, ToolEvent, ToolPolicy, ToolResult, TurnCommand,
|
||||
};
|
||||
use galaxy_agent_rig::{OpenAICompatibleRuntime, OpenAICompatibleRuntimeConfig};
|
||||
use galaxy_agent_rig::{
|
||||
ChatGPTSubscriptionRuntime, ChatGPTSubscriptionRuntimeConfig, OpenAICompatibleRuntime,
|
||||
OpenAICompatibleRuntimeConfig,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
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::runtime::{RuntimeResponseConfig, RuntimeResponseTranslator};
|
||||
use crate::server::server_api::AIApiError;
|
||||
use crate::settings::OpenAIProviderKind;
|
||||
|
||||
pub(crate) fn rig_openai_response_stream(
|
||||
config: OpenAIClientConfig,
|
||||
@@ -35,21 +39,41 @@ pub(crate) fn rig_openai_response_stream(
|
||||
let skill_path_origin = params.session_context.skill_path_origin();
|
||||
let prepared = prepare_rig_turn(&config, params, supported_tools, supported_cli_agent_tools);
|
||||
let model_id = prepared.request.model.as_str().to_string();
|
||||
let runtime = OpenAICompatibleRuntime::new(OpenAICompatibleRuntimeConfig {
|
||||
base_url: config.base_url,
|
||||
api_key: config.api_key,
|
||||
model: model_id.clone(),
|
||||
max_output_tokens: config.max_output_tokens.map(u64::from),
|
||||
supports_system_messages: config.supports_system_messages,
|
||||
});
|
||||
rig_response_stream(
|
||||
runtime,
|
||||
prepared,
|
||||
skill_path_origin,
|
||||
config.max_input_tokens,
|
||||
"rig_openai_compatible",
|
||||
cancellation_rx,
|
||||
)
|
||||
match config.kind {
|
||||
OpenAIProviderKind::OpenAICompatible => {
|
||||
let runtime = OpenAICompatibleRuntime::new(OpenAICompatibleRuntimeConfig {
|
||||
base_url: config.base_url,
|
||||
api_key: config.api_key,
|
||||
model: model_id.clone(),
|
||||
max_output_tokens: config.max_output_tokens.map(u64::from),
|
||||
supports_system_messages: config.supports_system_messages,
|
||||
});
|
||||
rig_response_stream(
|
||||
runtime,
|
||||
prepared,
|
||||
skill_path_origin,
|
||||
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(
|
||||
|
||||
@@ -113,7 +113,15 @@ fn prepare_rig_turn_for_provider(
|
||||
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 mut new_messages = input_messages(input, tool_results);
|
||||
@@ -406,6 +414,16 @@ enum RigRequestMode {
|
||||
|
||||
fn request_mode(inputs: &[AIAgentInput]) -> RigRequestMode {
|
||||
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!(
|
||||
input,
|
||||
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",
|
||||
),
|
||||
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");
|
||||
|
||||
@@ -9,8 +9,10 @@ use warp_multi_agent_api::ToolType;
|
||||
|
||||
use super::{input_messages, prepare_bedrock_rig_turn, prepare_rig_turn, tool_definitions};
|
||||
use crate::ai::agent::api::RequestParams;
|
||||
use crate::ai::agent::task::TaskId;
|
||||
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::openai::client::OpenAIClientConfig;
|
||||
@@ -18,9 +20,11 @@ use crate::ai::skills::SkillDescriptor;
|
||||
|
||||
fn config() -> OpenAIClientConfig {
|
||||
OpenAIClientConfig {
|
||||
kind: crate::settings::OpenAIProviderKind::OpenAICompatible,
|
||||
base_url: "http://localhost:4000/v1".to_string(),
|
||||
api_key: None,
|
||||
model: Some("provider-model".to_string()),
|
||||
reasoning_effort: None,
|
||||
max_input_tokens: Some(128_000),
|
||||
max_output_tokens: Some(8_192),
|
||||
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"));
|
||||
}
|
||||
|
||||
#[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]
|
||||
fn rig_prompt_requires_matching_project_skills_to_be_read_before_action() {
|
||||
let skill_path = LocalOrRemotePath::Local(PathBuf::from(
|
||||
|
||||
Reference in New Issue
Block a user