mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
feat: add AWS Bedrock LLM provider via native Converse API (#713)
* feat: add AWS Bedrock LLM provider via native Converse API * fix: use JSON parsing for tool result error detection instead of brittle substring matching * refactor: extract duplicated inference config builder into helper function * fix: address review feedback — safe casts, input validation, and tests - Safe u32→i32 cast for max_tokens using try_from with clamp - Remove brittle string-based error detection fallback for tool results - Validate BEDROCK_CROSS_REGION against allowed values (us/eu/apac/global) - Validate message list is non-empty before Converse API call - Log when using default us-east-1 region - Update llm_backend doc comment to list all backends - Add tests for build_inference_config and empty message handling * fix: persist AWS_PROFILE for Bedrock named profile auth The wizard collected the profile name but only printed a hint to set it manually. Now it saves to settings and writes AWS_PROFILE to the bootstrap .env, consistent with how BEDROCK_REGION and other Bedrock settings are persisted. * feat: gate AWS Bedrock behind optional `bedrock` feature flag The AWS SDK dependencies (aws-config, aws-sdk-bedrockruntime, aws-smithy-types) require cmake and a C compiler to build aws-lc-sys. Gate them behind an opt-in `bedrock` feature flag so default builds are unaffected. Build with: cargo build --features bedrock All config, settings, and wizard code stays unconditional (no AWS deps) so users can configure Bedrock even without the feature compiled — they get a clear error at startup directing them to rebuild. * fix: address review feedback and adapt Bedrock provider to registry architecture (takeover #345) - Resolve merge conflicts with main's registry-based provider system - Add missing cache_creation_input_tokens/cache_read_input_tokens fields - Add missing content_parts field in test ChatMessage - Fix string literal type mismatches in wizard env_vars (.to_string()) - Remove non-functional bearer token auth (AWS_BEARER_TOKEN_BEDROCK) from wizard and documentation per reviewer feedback from @zmanian and @serrrfirat - Remove stale BEDROCK_ACCESS_KEY proxy entry from provider table - Update Bedrock provider to use is_bedrock string check (LlmBackend enum removed) - Add bedrock_profile fallback from settings in config resolution [skip-regression-check] Co-Authored-By: cgorski <[email protected]> Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: use main's Cargo.lock as base to preserve dependency versions Regenerating Cargo.lock from scratch caused transitive dependency version drift that broke the html_to_markdown fixture test in CI. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: bedrock config bugs — spurious warning, alias normalization, profile fallback - Move is_bedrock check before unknown-backend warning to prevent spurious "unknown backend" log for bedrock users - Normalize backend aliases ("aws", "aws_bedrock") to "bedrock" so the provider factory matches correctly - Add settings.bedrock_profile fallback for AWS_PROFILE, consistent with region and cross_region resolution [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address Copilot review feedback — bearer token cleanup, stop_sequences, model dedup - Remove stale bearer token refs from setup README and CHANGELOG - Remove dead bedrock_api_key secret injection mapping - Pass stop_sequences through to Bedrock InferenceConfiguration - Remove "API key" from wizard menu description (bearer token removed) - Skip duplicate LLM_MODEL write for bedrock backend in wizard - Fix cargo fmt formatting [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address review feedback — async new(), remove LiteLLM entry, wizard fixes - Remove dead LiteLLM-based bedrock entry from providers.json (native Converse API intercepts before registry lookup) - Make BedrockProvider::new() async to avoid block_in_place panic in current_thread runtimes; propagate async to create_llm_provider, build_provider_chain, and init_llm - Document CMake build prerequisite in docs/LLM_PROVIDERS.md - Clear bedrock_profile when user selects "default credentials" in wizard - Fix selected_model clearing to match established pattern (conditional on provider switch, not unconditional) - Add regression tests for bedrock model preservation and profile clearing Addresses review feedback from @zmanian on PR #713. Streaming support tracked in #741. [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address remaining review comments — CLAUDE.md backends, wizard UX - Add `bedrock` to CLAUDE.md inline backend list (#10) - Skip full setup re-run when keeping existing Bedrock config (#11) - Clear stale bedrock_profile on empty named-profile input (#12) - Add regression test for empty profile clearing Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Chris Gorski <[email protected]> Co-authored-by: cgorski <[email protected]> Co-authored-by: Claude Opus 4.6 <[email protected]>
This commit is contained in:
co-authored by
Chris Gorski
cgorski
Claude Opus 4.6
parent
30d81fcdee
commit
d73e35cfb0
+3
-3
@@ -334,7 +334,7 @@ impl AppBuilder {
|
||||
/// Delegates to `build_provider_chain` which applies all decorators
|
||||
/// (retry, smart routing, failover, circuit breaker, response cache).
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub fn init_llm(
|
||||
pub async fn init_llm(
|
||||
&self,
|
||||
) -> Result<
|
||||
(
|
||||
@@ -345,7 +345,7 @@ impl AppBuilder {
|
||||
anyhow::Error,
|
||||
> {
|
||||
let (llm, cheap_llm, recording_handle) =
|
||||
crate::llm::build_provider_chain(&self.config.llm, self.session.clone())?;
|
||||
crate::llm::build_provider_chain(&self.config.llm, self.session.clone()).await?;
|
||||
Ok((llm, cheap_llm, recording_handle))
|
||||
}
|
||||
|
||||
@@ -820,7 +820,7 @@ impl AppBuilder {
|
||||
let (llm, cheap_llm, recording_handle) = if let Some(llm) = self.llm_override.take() {
|
||||
(llm, None, None)
|
||||
} else {
|
||||
self.init_llm()?
|
||||
self.init_llm().await?
|
||||
};
|
||||
let (safety, tools, embeddings, workspace) = self.init_tools(&llm).await?;
|
||||
|
||||
|
||||
+62
-4
@@ -86,6 +86,19 @@ pub struct RegistryProviderConfig {
|
||||
pub oauth_token: Option<SecretString>,
|
||||
}
|
||||
|
||||
/// Configuration for AWS Bedrock (native Converse API).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BedrockConfig {
|
||||
/// AWS region (e.g. "us-east-1").
|
||||
pub region: String,
|
||||
/// Bedrock model ID (e.g. "anthropic.claude-opus-4-6-v1").
|
||||
pub model: String,
|
||||
/// Cross-region inference prefix: "us", "eu", "apac", "global", or None.
|
||||
pub cross_region: Option<String>,
|
||||
/// AWS named profile (for SSO / assume-role workflows).
|
||||
pub profile: Option<String>,
|
||||
}
|
||||
|
||||
/// LLM provider configuration.
|
||||
///
|
||||
/// NearAI remains the default backend with its own config struct (session auth).
|
||||
@@ -101,8 +114,10 @@ pub struct LlmConfig {
|
||||
/// NEAR AI config (always populated, also used for embeddings).
|
||||
pub nearai: NearAiConfig,
|
||||
/// Resolved provider config for registry-based providers.
|
||||
/// `None` when backend is "nearai".
|
||||
/// `None` when backend is "nearai" or "bedrock".
|
||||
pub provider: Option<RegistryProviderConfig>,
|
||||
/// AWS Bedrock config (populated when backend=bedrock, requires --features bedrock).
|
||||
pub bedrock: Option<BedrockConfig>,
|
||||
/// HTTP request timeout in seconds for LLM API calls.
|
||||
/// Default: 120. Increase for local LLMs (Ollama, vLLM, LM Studio) that
|
||||
/// need more time for prompt evaluation on consumer hardware.
|
||||
@@ -169,6 +184,7 @@ impl LlmConfig {
|
||||
smart_routing_cascade: false,
|
||||
},
|
||||
provider: None,
|
||||
bedrock: None,
|
||||
request_timeout_secs: 120,
|
||||
}
|
||||
}
|
||||
@@ -200,8 +216,10 @@ impl LlmConfig {
|
||||
let backend_lower = backend.to_lowercase();
|
||||
let is_nearai =
|
||||
backend_lower == "nearai" || backend_lower == "near_ai" || backend_lower == "near";
|
||||
let is_bedrock =
|
||||
backend_lower == "bedrock" || backend_lower == "aws_bedrock" || backend_lower == "aws";
|
||||
|
||||
if !is_nearai && registry.find(&backend_lower).is_none() {
|
||||
if !is_nearai && !is_bedrock && registry.find(&backend_lower).is_none() {
|
||||
tracing::warn!(
|
||||
"Unknown LLM backend '{}'. Will attempt as openai_compatible fallback.",
|
||||
backend
|
||||
@@ -248,8 +266,8 @@ impl LlmConfig {
|
||||
smart_routing_cascade: parse_optional_env("SMART_ROUTING_CASCADE", true)?,
|
||||
};
|
||||
|
||||
// Resolve registry provider config (for non-NearAI backends)
|
||||
let provider = if is_nearai {
|
||||
// Resolve registry provider config (for non-NearAI, non-Bedrock backends)
|
||||
let provider = if is_nearai || is_bedrock {
|
||||
None
|
||||
} else {
|
||||
Some(Self::resolve_registry_provider(
|
||||
@@ -259,11 +277,50 @@ impl LlmConfig {
|
||||
)?)
|
||||
};
|
||||
|
||||
let bedrock = if is_bedrock {
|
||||
let explicit_region =
|
||||
optional_env("BEDROCK_REGION")?.or_else(|| settings.bedrock_region.clone());
|
||||
if explicit_region.is_none() {
|
||||
tracing::info!("BEDROCK_REGION not set, defaulting to us-east-1");
|
||||
}
|
||||
let region = explicit_region.unwrap_or_else(|| "us-east-1".to_string());
|
||||
let model = optional_env("BEDROCK_MODEL")?
|
||||
.or_else(|| settings.selected_model.clone())
|
||||
.ok_or_else(|| ConfigError::MissingRequired {
|
||||
key: "BEDROCK_MODEL".to_string(),
|
||||
hint: "Set BEDROCK_MODEL when LLM_BACKEND=bedrock".to_string(),
|
||||
})?;
|
||||
let cross_region = optional_env("BEDROCK_CROSS_REGION")?
|
||||
.or_else(|| settings.bedrock_cross_region.clone());
|
||||
if let Some(ref cr) = cross_region
|
||||
&& !matches!(cr.as_str(), "us" | "eu" | "apac" | "global")
|
||||
{
|
||||
return Err(ConfigError::InvalidValue {
|
||||
key: "BEDROCK_CROSS_REGION".to_string(),
|
||||
message: format!(
|
||||
"'{}' is not valid, expected one of: us, eu, apac, global",
|
||||
cr
|
||||
),
|
||||
});
|
||||
}
|
||||
let profile = optional_env("AWS_PROFILE")?.or_else(|| settings.bedrock_profile.clone());
|
||||
Some(BedrockConfig {
|
||||
region,
|
||||
model,
|
||||
cross_region,
|
||||
profile,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let request_timeout_secs = parse_optional_env("LLM_REQUEST_TIMEOUT_SECS", 120)?;
|
||||
|
||||
Ok(Self {
|
||||
backend: if is_nearai {
|
||||
"nearai".to_string()
|
||||
} else if is_bedrock {
|
||||
"bedrock".to_string()
|
||||
} else if let Some(ref p) = provider {
|
||||
p.provider_id.clone()
|
||||
} else {
|
||||
@@ -272,6 +329,7 @@ impl LlmConfig {
|
||||
session,
|
||||
nearai,
|
||||
provider,
|
||||
bedrock,
|
||||
request_timeout_secs,
|
||||
})
|
||||
}
|
||||
|
||||
+3
-1
@@ -37,7 +37,9 @@ pub use self::database::{DatabaseBackend, DatabaseConfig, SslMode, default_libsq
|
||||
pub use self::embeddings::EmbeddingsConfig;
|
||||
pub use self::heartbeat::HeartbeatConfig;
|
||||
pub use self::hygiene::HygieneConfig;
|
||||
pub use self::llm::{CacheRetention, LlmConfig, NearAiConfig, RegistryProviderConfig};
|
||||
pub use self::llm::{
|
||||
BedrockConfig, CacheRetention, LlmConfig, NearAiConfig, RegistryProviderConfig,
|
||||
};
|
||||
pub use self::routines::RoutineConfig;
|
||||
pub use self::safety::SafetyConfig;
|
||||
pub use self::sandbox::{ClaudeCodeConfig, SandboxModeConfig};
|
||||
|
||||
+1148
File diff suppressed because it is too large
Load Diff
+40
-3
@@ -6,8 +6,11 @@
|
||||
//! - **Anthropic**: Direct API access with your own key
|
||||
//! - **Ollama**: Local model inference
|
||||
//! - **OpenAI-compatible**: Any endpoint that speaks the OpenAI API
|
||||
//! - **AWS Bedrock**: Native Converse API via aws-sdk-bedrockruntime
|
||||
|
||||
mod anthropic_oauth;
|
||||
#[cfg(feature = "bedrock")]
|
||||
mod bedrock;
|
||||
pub mod circuit_breaker;
|
||||
pub mod costs;
|
||||
pub mod failover;
|
||||
@@ -57,7 +60,7 @@ use crate::error::LlmError;
|
||||
///
|
||||
/// - NearAI backend: Uses session manager for authentication
|
||||
/// - Registry providers: Looked up by protocol and constructed generically
|
||||
pub fn create_llm_provider(
|
||||
pub async fn create_llm_provider(
|
||||
config: &LlmConfig,
|
||||
session: Arc<SessionManager>,
|
||||
) -> Result<Arc<dyn LlmProvider>, LlmError> {
|
||||
@@ -67,6 +70,21 @@ pub fn create_llm_provider(
|
||||
return create_llm_provider_with_config(&config.nearai, session, timeout);
|
||||
}
|
||||
|
||||
// Bedrock uses a native AWS SDK, not the rig-core registry
|
||||
if config.backend == "bedrock" {
|
||||
#[cfg(feature = "bedrock")]
|
||||
{
|
||||
return create_bedrock_provider(config).await;
|
||||
}
|
||||
#[cfg(not(feature = "bedrock"))]
|
||||
{
|
||||
return Err(LlmError::RequestFailed {
|
||||
provider: "bedrock".to_string(),
|
||||
reason: "Bedrock support not compiled. Rebuild with --features bedrock".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let reg_config = config
|
||||
.provider
|
||||
.as_ref()
|
||||
@@ -120,6 +138,24 @@ fn create_registry_provider(
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "bedrock")]
|
||||
async fn create_bedrock_provider(config: &LlmConfig) -> Result<Arc<dyn LlmProvider>, LlmError> {
|
||||
let br = config
|
||||
.bedrock
|
||||
.as_ref()
|
||||
.ok_or_else(|| LlmError::AuthFailed {
|
||||
provider: "bedrock".to_string(),
|
||||
})?;
|
||||
|
||||
let provider = bedrock::BedrockProvider::new(br).await?;
|
||||
tracing::info!(
|
||||
"Using AWS Bedrock (Converse API, region: {}, model: {})",
|
||||
br.region,
|
||||
provider.active_model_name(),
|
||||
);
|
||||
Ok(Arc::new(provider))
|
||||
}
|
||||
|
||||
fn create_openai_compat_from_registry(
|
||||
config: &RegistryProviderConfig,
|
||||
) -> Result<Arc<dyn LlmProvider>, LlmError> {
|
||||
@@ -344,7 +380,7 @@ pub fn create_cheap_llm_provider(
|
||||
/// This is the single source of truth for provider chain construction,
|
||||
/// called by both `main.rs` and `app.rs`.
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub fn build_provider_chain(
|
||||
pub async fn build_provider_chain(
|
||||
config: &LlmConfig,
|
||||
session: Arc<SessionManager>,
|
||||
) -> Result<
|
||||
@@ -355,7 +391,7 @@ pub fn build_provider_chain(
|
||||
),
|
||||
LlmError,
|
||||
> {
|
||||
let llm = create_llm_provider(config, session.clone())?;
|
||||
let llm = create_llm_provider(config, session.clone()).await?;
|
||||
tracing::info!("LLM provider initialized: {}", llm.model_name());
|
||||
|
||||
// 1. Retry
|
||||
@@ -522,6 +558,7 @@ mod tests {
|
||||
session: SessionConfig::default(),
|
||||
nearai: test_nearai_config(),
|
||||
provider: None,
|
||||
bedrock: None,
|
||||
request_timeout_secs: 120,
|
||||
}
|
||||
}
|
||||
|
||||
+13
-1
@@ -47,7 +47,7 @@ pub struct Settings {
|
||||
pub secrets_master_key_hex: Option<String>,
|
||||
|
||||
// === Step 3: Inference Provider ===
|
||||
/// LLM backend: "nearai", "anthropic", "openai", "ollama", "openai_compatible".
|
||||
/// LLM backend: "nearai", "anthropic", "openai", "ollama", "openai_compatible", "tinfoil", "bedrock".
|
||||
#[serde(default)]
|
||||
pub llm_backend: Option<String>,
|
||||
|
||||
@@ -59,6 +59,18 @@ pub struct Settings {
|
||||
#[serde(default)]
|
||||
pub openai_compatible_base_url: Option<String>,
|
||||
|
||||
/// Bedrock region (when llm_backend = "bedrock").
|
||||
#[serde(default)]
|
||||
pub bedrock_region: Option<String>,
|
||||
|
||||
/// Bedrock cross-region inference prefix (when llm_backend = "bedrock").
|
||||
#[serde(default)]
|
||||
pub bedrock_cross_region: Option<String>,
|
||||
|
||||
/// AWS profile name for Bedrock (when llm_backend = "bedrock").
|
||||
#[serde(default)]
|
||||
pub bedrock_profile: Option<String>,
|
||||
|
||||
// === Step 4: Model Selection ===
|
||||
/// Currently selected model.
|
||||
#[serde(default)]
|
||||
|
||||
+2
-1
@@ -174,6 +174,7 @@ env-var mode or skipped secrets.
|
||||
| Ollama | None | - | - |
|
||||
| OpenRouter¹ | API key | `llm_compatible_api_key` | `LLM_API_KEY` |
|
||||
| OpenAI-compatible¹ | Optional API key | `llm_compatible_api_key` | `LLM_API_KEY` |
|
||||
| AWS Bedrock | AWS credentials (IAM, SSO, instance roles) | - | - |
|
||||
|
||||
¹ OpenRouter and OpenAI-compatible share the same secret name and env var because
|
||||
OpenRouter is stored as `llm_backend = "openai_compatible"` under the hood.
|
||||
@@ -479,7 +480,7 @@ pub struct Settings {
|
||||
pub secrets_master_key_source: KeySource, // Keychain | Env | None
|
||||
|
||||
// Step 3: Inference
|
||||
pub llm_backend: Option<String>, // "nearai" | "anthropic" | "openai" | "ollama" | "openai_compatible"
|
||||
pub llm_backend: Option<String>, // "nearai" | "anthropic" | "openai" | "ollama" | "openai_compatible" | "bedrock"
|
||||
pub ollama_base_url: Option<String>,
|
||||
pub openai_compatible_base_url: Option<String>,
|
||||
|
||||
|
||||
+198
-7
@@ -827,9 +827,16 @@ impl SetupWizard {
|
||||
print_info(&format!("Current provider: {}", display));
|
||||
println!();
|
||||
|
||||
let is_known = current == "nearai" || registry.is_known(¤t);
|
||||
let is_known =
|
||||
current == "nearai" || current == "bedrock" || registry.is_known(¤t);
|
||||
|
||||
if is_known && confirm("Keep current provider?", true).map_err(SetupError::Io)? {
|
||||
if current == "bedrock" {
|
||||
// Keeping the existing Bedrock config — no need to re-run
|
||||
// the full setup flow (region, auth, cross-region).
|
||||
print_info("Keeping existing AWS Bedrock configuration.");
|
||||
return Ok(());
|
||||
}
|
||||
return self.run_provider_setup(¤t, ®istry).await;
|
||||
}
|
||||
|
||||
@@ -844,10 +851,10 @@ impl SetupWizard {
|
||||
print_info("Select your inference provider:");
|
||||
println!();
|
||||
|
||||
// Build menu: NearAI first, then all registry providers with setup hints
|
||||
// Build menu: NearAI first, then all registry providers with setup hints, then Bedrock
|
||||
let selectable = registry.selectable();
|
||||
let mut options: Vec<String> = Vec::with_capacity(1 + selectable.len());
|
||||
let mut provider_ids: Vec<String> = Vec::with_capacity(1 + selectable.len());
|
||||
let mut options: Vec<String> = Vec::with_capacity(2 + selectable.len());
|
||||
let mut provider_ids: Vec<String> = Vec::with_capacity(2 + selectable.len());
|
||||
|
||||
options.push("NEAR AI - multi-model access via NEAR account".to_string());
|
||||
provider_ids.push("nearai".to_string());
|
||||
@@ -865,11 +872,19 @@ impl SetupWizard {
|
||||
provider_ids.push(def.id.clone());
|
||||
}
|
||||
|
||||
// Bedrock is a special case (native AWS SDK, not registry-based)
|
||||
options.push("AWS Bedrock - Claude & other models via AWS (IAM, SSO)".to_string());
|
||||
provider_ids.push("bedrock".to_string());
|
||||
|
||||
let option_refs: Vec<&str> = options.iter().map(|s| s.as_str()).collect();
|
||||
let choice = select_one("Provider:", &option_refs).map_err(SetupError::Io)?;
|
||||
let selected_id = &provider_ids[choice];
|
||||
|
||||
self.run_provider_setup(selected_id, ®istry).await?;
|
||||
if selected_id == "bedrock" {
|
||||
self.setup_bedrock().await?;
|
||||
} else {
|
||||
self.run_provider_setup(selected_id, ®istry).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1230,6 +1245,95 @@ impl SetupWizard {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// AWS Bedrock provider setup: region, auth, and cross-region config.
|
||||
async fn setup_bedrock(&mut self) -> Result<(), SetupError> {
|
||||
if self.settings.llm_backend.as_deref() != Some("bedrock") {
|
||||
self.settings.selected_model = None;
|
||||
}
|
||||
self.settings.llm_backend = Some("bedrock".to_string());
|
||||
|
||||
// Region
|
||||
let default_region = self
|
||||
.settings
|
||||
.bedrock_region
|
||||
.as_deref()
|
||||
.unwrap_or("us-east-1");
|
||||
|
||||
let region_input =
|
||||
optional_input("AWS region", Some(&format!("default: {}", default_region)))
|
||||
.map_err(SetupError::Io)?;
|
||||
|
||||
let region = region_input.unwrap_or_else(|| default_region.to_string());
|
||||
self.settings.bedrock_region = Some(region.clone());
|
||||
|
||||
// Auth method
|
||||
print_info("Select authentication method:");
|
||||
println!();
|
||||
let auth_options = &[
|
||||
"AWS default credentials (env vars, ~/.aws/credentials, IAM roles)",
|
||||
"AWS named profile (SSO / assume-role)",
|
||||
];
|
||||
let auth_choice = select_one("Auth:", auth_options).map_err(SetupError::Io)?;
|
||||
|
||||
match auth_choice {
|
||||
0 => {
|
||||
// Default AWS credentials — clear any stale named profile
|
||||
self.settings.bedrock_profile = None;
|
||||
print_info(
|
||||
"Using default AWS credential chain (env vars, ~/.aws/credentials, IAM roles).",
|
||||
);
|
||||
}
|
||||
1 => {
|
||||
// Named profile
|
||||
let profile =
|
||||
input("AWS profile name (from ~/.aws/config)").map_err(SetupError::Io)?;
|
||||
if profile.trim().is_empty() {
|
||||
// Empty input clears any previously configured profile
|
||||
self.settings.bedrock_profile = None;
|
||||
print_info("AWS profile cleared; using default AWS credential chain instead.");
|
||||
} else {
|
||||
self.settings.bedrock_profile = Some(profile.clone());
|
||||
print_success(&format!("AWS profile '{}' saved", profile));
|
||||
}
|
||||
}
|
||||
_ => return Err(SetupError::Config("Invalid auth selection".to_string())),
|
||||
}
|
||||
|
||||
self.setup_bedrock_cross_region()
|
||||
}
|
||||
|
||||
/// Bedrock cross-region inference prefix selection (sub-step of setup_bedrock).
|
||||
fn setup_bedrock_cross_region(&mut self) -> Result<(), SetupError> {
|
||||
print_info("Cross-region inference routes requests across AWS regions for capacity:");
|
||||
println!();
|
||||
let cross_options = &[
|
||||
"us - route within US regions (recommended for us-east-1)",
|
||||
"global - route to any AWS region worldwide",
|
||||
"eu - route within European regions",
|
||||
"apac - route within Asia-Pacific regions",
|
||||
"none - single-region only (no cross-region routing)",
|
||||
];
|
||||
let cross_choice = select_one("Cross-region:", cross_options).map_err(SetupError::Io)?;
|
||||
|
||||
let cross_region = match cross_choice {
|
||||
0 => Some("us".to_string()),
|
||||
1 => Some("global".to_string()),
|
||||
2 => Some("eu".to_string()),
|
||||
3 => Some("apac".to_string()),
|
||||
4 => None,
|
||||
_ => None,
|
||||
};
|
||||
self.settings.bedrock_cross_region = cross_region;
|
||||
|
||||
let region = self
|
||||
.settings
|
||||
.bedrock_region
|
||||
.as_deref()
|
||||
.unwrap_or("us-east-1");
|
||||
print_success(&format!("AWS Bedrock configured (region: {})", region));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Generic OpenAI-compatible setup: base URL + optional API key.
|
||||
async fn setup_openai_compatible_generic(
|
||||
&mut self,
|
||||
@@ -1412,6 +1516,14 @@ impl SetupWizard {
|
||||
self.settings.selected_model = Some(model_id.clone());
|
||||
print_success(&format!("Selected {}", model_id));
|
||||
}
|
||||
} else if backend == "bedrock" {
|
||||
let model_id = input("Bedrock model ID (e.g., anthropic.claude-opus-4-6-v1)")
|
||||
.map_err(SetupError::Io)?;
|
||||
if model_id.is_empty() {
|
||||
return Err(SetupError::Config("Model ID is required".to_string()));
|
||||
}
|
||||
self.settings.selected_model = Some(model_id.clone());
|
||||
print_success(&format!("Selected {}", model_id));
|
||||
} else {
|
||||
// Unknown provider, manual entry
|
||||
let model_id = input("Model name (e.g., meta-llama/Llama-3-8b-chat-hf)")
|
||||
@@ -1495,10 +1607,11 @@ impl SetupWizard {
|
||||
smart_routing_cascade: true,
|
||||
},
|
||||
provider: None,
|
||||
bedrock: None,
|
||||
request_timeout_secs: 120,
|
||||
};
|
||||
|
||||
match create_llm_provider(&config, session) {
|
||||
match create_llm_provider(&config, session).await {
|
||||
Ok(provider) => match provider.list_models().await {
|
||||
Ok(models) => models,
|
||||
Err(e) => {
|
||||
@@ -2315,12 +2428,29 @@ impl SetupWizard {
|
||||
if let Some(ref url) = self.settings.ollama_base_url {
|
||||
env_vars.push(("OLLAMA_BASE_URL".to_string(), url.clone()));
|
||||
}
|
||||
if let Some(ref region) = self.settings.bedrock_region {
|
||||
env_vars.push(("BEDROCK_REGION".to_string(), region.clone()));
|
||||
}
|
||||
if self.settings.llm_backend.as_deref() == Some("bedrock") {
|
||||
if let Some(ref model) = self.settings.selected_model {
|
||||
env_vars.push(("BEDROCK_MODEL".to_string(), model.clone()));
|
||||
}
|
||||
if let Some(ref cross) = self.settings.bedrock_cross_region {
|
||||
env_vars.push(("BEDROCK_CROSS_REGION".to_string(), cross.clone()));
|
||||
}
|
||||
if let Some(ref profile) = self.settings.bedrock_profile {
|
||||
env_vars.push(("AWS_PROFILE".to_string(), profile.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
// Model name: same chicken-and-egg — Config::from_env() resolves the
|
||||
// model before the DB is connected, so we must persist it to .env.
|
||||
// Write the backend-specific env var so the correct resolution path
|
||||
// picks it up (looked up from the provider registry).
|
||||
if let Some(ref model) = self.settings.selected_model {
|
||||
// Bedrock model is already written above as BEDROCK_MODEL, skip here.
|
||||
if self.settings.llm_backend.as_deref() != Some("bedrock")
|
||||
&& let Some(ref model) = self.settings.selected_model
|
||||
{
|
||||
let backend_str = self.settings.llm_backend.as_deref().unwrap_or("nearai");
|
||||
let model_env = registry.model_env_var(backend_str);
|
||||
env_vars.push((model_env.to_string(), model.clone()));
|
||||
@@ -2605,6 +2735,7 @@ impl SetupWizard {
|
||||
"openai" => "OpenAI",
|
||||
"ollama" => "Ollama",
|
||||
"openai_compatible" => "OpenAI-compatible",
|
||||
"bedrock" => "AWS Bedrock",
|
||||
other => other,
|
||||
};
|
||||
println!(" Provider: {}", display);
|
||||
@@ -3569,6 +3700,66 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression: Bedrock setup_bedrock() should preserve selected_model
|
||||
/// when re-entering the same provider (matches pattern from #600).
|
||||
#[test]
|
||||
fn test_bedrock_same_provider_preserves_model() {
|
||||
let mut wizard = SetupWizard::new();
|
||||
wizard.settings.llm_backend = Some("bedrock".to_string());
|
||||
wizard.settings.selected_model = Some("anthropic.claude-opus-4-6-v1".to_string());
|
||||
|
||||
// Simulate the conditional clearing logic from setup_bedrock()
|
||||
if wizard.settings.llm_backend.as_deref() != Some("bedrock") {
|
||||
wizard.settings.selected_model = None;
|
||||
}
|
||||
wizard.settings.llm_backend = Some("bedrock".to_string());
|
||||
|
||||
assert_eq!(
|
||||
wizard.settings.selected_model.as_deref(),
|
||||
Some("anthropic.claude-opus-4-6-v1"),
|
||||
"bedrock model should be preserved when re-selecting bedrock"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression: switching from another provider to bedrock must clear
|
||||
/// selected_model, and choosing "default credentials" must clear
|
||||
/// bedrock_profile.
|
||||
#[test]
|
||||
fn test_bedrock_clears_stale_profile_on_default_creds() {
|
||||
let mut wizard = SetupWizard::new();
|
||||
wizard.settings.llm_backend = Some("bedrock".to_string());
|
||||
wizard.settings.bedrock_profile = Some("old-sso-profile".to_string());
|
||||
|
||||
// Simulate auth_choice == 0 (default credentials) clearing the profile
|
||||
wizard.settings.bedrock_profile = None;
|
||||
|
||||
assert!(
|
||||
wizard.settings.bedrock_profile.is_none(),
|
||||
"bedrock_profile should be cleared when selecting default credentials"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression: empty profile input in named-profile auth should clear
|
||||
/// any previously configured profile instead of leaving it stale.
|
||||
#[test]
|
||||
fn test_bedrock_empty_profile_clears_existing() {
|
||||
let mut wizard = SetupWizard::new();
|
||||
wizard.settings.bedrock_profile = Some("old-profile".to_string());
|
||||
|
||||
// Simulate auth_choice == 1 with empty input
|
||||
let profile = "".to_string();
|
||||
if profile.trim().is_empty() {
|
||||
wizard.settings.bedrock_profile = None;
|
||||
} else {
|
||||
wizard.settings.bedrock_profile = Some(profile);
|
||||
}
|
||||
|
||||
assert!(
|
||||
wizard.settings.bedrock_profile.is_none(),
|
||||
"empty profile input should clear existing bedrock_profile"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_run_provider_setup_no_setup_hint() {
|
||||
// A provider with setup: None should not error. It should set the
|
||||
|
||||
Reference in New Issue
Block a user