mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-26 23:50:17 +00:00
* refactor(setup): extract init logic from wizard into owning modules Move database, LLM model discovery, and secrets initialization logic out of the setup wizard and into their owning modules, following the CLAUDE.md principle that module-specific initialization must live in the owning module as a public factory function. Database (src/db/mod.rs, src/config/database.rs): - Add DatabaseConfig::from_postgres_url() and from_libsql_path() - Add connect_without_migrations() for connectivity testing - Add validate_postgres() returning structured PgDiagnostic results LLM (src/llm/models.rs — new file): - Extract 8 model-fetching functions from wizard.rs (~380 lines) - fetch_anthropic_models, fetch_openai_models, fetch_ollama_models, fetch_openai_compatible_models, build_nearai_model_fetch_config, and OpenAI sorting/filtering helpers Secrets (src/secrets/mod.rs): - Add resolve_master_key() unifying env var + keychain resolution - Add crypto_from_hex() convenience wrapper Wizard restructuring (src/setup/wizard.rs): - Replace cfg-gated db_pool/db_backend fields with generic db: Option<Arc<dyn Database>> + db_handles: Option<DatabaseHandles> - Delete 6 backend-specific methods (reconnect_postgres/libsql, test_database_connection_postgres/libsql, run_migrations_postgres/ libsql, create_postgres/libsql_secrets_store) - Simplify persist_settings, try_load_existing_settings, persist_session_to_db, init_secrets_context to backend-agnostic implementations using the new module factories - Eliminate all references to deadpool_postgres, PoolConfig, LibSqlBackend, Store::from_pool, refinery::embed_migrations Net: -878 lines from wizard, +395 lines in owning modules, +378 new. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * test(settings): add wizard re-run regression tests Add 10 tests covering settings preservation during wizard re-runs: - provider_only rerun preserves channels/embeddings/heartbeat - channels_only rerun preserves provider/model/embeddings - quick mode rerun preserves prior channels and heartbeat - full rerun same provider preserves model through merge - full rerun different provider clears model through merge - incremental persist doesn't clobber prior steps - switching DB backend allows fresh connection settings - merge preserves true booleans when overlay has default false - embeddings survive rerun that skips step 5 These cover the scenarios where re-running the wizard would previously risk resetting models, providers, or channel settings. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * refactor(setup): eliminate cfg(feature) gates from wizard methods Replace compile-time #[cfg(feature)] dispatch in the wizard with runtime dispatch via DatabaseBackend enum and cfg!() macro constants. - Merge step_database_postgres + step_database_libsql into step_database using runtime backend selection - Rewrite auto_setup_database without feature gates - Remove cfg(feature = "postgres") from mask_password_in_url (pure fn) - Remove cfg(feature = "postgres") from test_mask_password_in_url Only one internal #[cfg(feature = "postgres")] remains: guarding the call to db::validate_postgres() which is itself feature-gated. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * refactor(db): fold PG validation into connect_without_migrations Move PostgreSQL prerequisite validation (version >= 15, pgvector) from the wizard into connect_without_migrations() in the db module. The validation now returns DatabaseError directly with user-facing messages, eliminating the PgDiagnostic enum and the last #[cfg(feature)] gate from the wizard. The wizard's test_database_connection() is now a 5-line method that calls the db module factory and stores the result. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address PR review comments [skip-regression-check] - Use .as_ref().map() to avoid partial move of db_config.libsql_path (gemini-code-assist) - Default to available backend when DATABASE_BACKEND is invalid, not unconditionally to Postgres which may not be compiled (Copilot) - Match DatabaseBackend::Postgres explicitly instead of _ => wildcard in connect_with_handles, connect_without_migrations, and create_secrets_store to avoid silently routing LibSql configs through the Postgres path when libsql feature is disabled (Copilot) - Upgrade Ollama connection failure log from info to warn with the base URL for better visibility in wizard UX (Copilot) - Clarify crypto_from_hex doc: SecretsCrypto validates key length, not hex encoding (Copilot) Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address zmanian's PR review feedback [skip-regression-check] - Update src/setup/README.md to reflect Arc<dyn Database> flow - Remove stale "Test PostgreSQL connection" doc comment - Replace unwrap_or(0) in validate_postgres with descriptive error - Add NearAiConfig::for_model_discovery() constructor - Narrow pub to pub(crate) for internal model helpers Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address Copilot review comments (quick-mode postgres gate, empty env vars) [skip-regression-check] - Gate DATABASE_URL auto-detection on POSTGRES_AVAILABLE in quick mode so libsql-only builds don't attempt a postgres connection - Match empty-env-var filtering in key source detection to align with resolve_master_key() behavior - Filter empty strings to None in DatabaseConfig::from_libsql_path() for turso_url/turso_token Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> --------- Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
604 lines
20 KiB
Rust
604 lines
20 KiB
Rust
//! LLM integration for the agent.
|
|
//!
|
|
//! Supports multiple backends:
|
|
//! - **NEAR AI** (default): Session token or API key auth via Chat Completions API
|
|
//! - **OpenAI**: Direct API access with your own key
|
|
//! - **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 config;
|
|
pub mod costs;
|
|
pub mod error;
|
|
pub mod failover;
|
|
mod nearai_chat;
|
|
pub mod oauth_helpers;
|
|
mod provider;
|
|
mod reasoning;
|
|
pub mod recording;
|
|
pub mod registry;
|
|
pub mod response_cache;
|
|
pub mod retry;
|
|
mod rig_adapter;
|
|
pub mod session;
|
|
pub mod smart_routing;
|
|
|
|
pub mod image_models;
|
|
pub mod models;
|
|
pub mod reasoning_models;
|
|
pub mod vision_models;
|
|
|
|
pub use circuit_breaker::{CircuitBreakerConfig, CircuitBreakerProvider};
|
|
pub use config::{
|
|
BedrockConfig, CacheRetention, LlmConfig, NearAiConfig, OAUTH_PLACEHOLDER,
|
|
RegistryProviderConfig,
|
|
};
|
|
pub use error::LlmError;
|
|
pub use failover::{CooldownConfig, FailoverProvider};
|
|
pub use nearai_chat::{ModelInfo, NearAiChatProvider};
|
|
pub use provider::{
|
|
ChatMessage, CompletionRequest, CompletionResponse, ContentPart, FinishReason, ImageUrl,
|
|
LlmProvider, ModelMetadata, Role, ToolCall, ToolCompletionRequest, ToolCompletionResponse,
|
|
ToolDefinition, ToolResult,
|
|
};
|
|
pub use reasoning::{
|
|
ActionPlan, Reasoning, ReasoningContext, RespondOutput, RespondResult, SILENT_REPLY_TOKEN,
|
|
TOOL_INTENT_NUDGE, TokenUsage, ToolSelection, is_silent_reply, llm_signals_tool_intent,
|
|
};
|
|
pub use recording::RecordingLlm;
|
|
pub use registry::{ProviderDefinition, ProviderProtocol, ProviderRegistry};
|
|
pub use response_cache::{CachedProvider, ResponseCacheConfig};
|
|
pub use retry::{RetryConfig, RetryProvider};
|
|
pub use rig_adapter::RigAdapter;
|
|
pub use session::{SessionConfig, SessionManager, create_session_manager};
|
|
pub use smart_routing::{SmartRoutingConfig, SmartRoutingProvider, TaskComplexity};
|
|
|
|
use std::sync::Arc;
|
|
|
|
use rig::client::CompletionClient;
|
|
use secrecy::ExposeSecret;
|
|
|
|
// LlmConfig, NearAiConfig, RegistryProviderConfig, and LlmError are
|
|
// re-exported via `pub use` above from config and error submodules.
|
|
|
|
/// Create an LLM provider based on configuration.
|
|
///
|
|
/// - NearAI backend: Uses session manager for authentication
|
|
/// - Registry providers: Looked up by protocol and constructed generically
|
|
pub async fn create_llm_provider(
|
|
config: &LlmConfig,
|
|
session: Arc<SessionManager>,
|
|
) -> Result<Arc<dyn LlmProvider>, LlmError> {
|
|
let timeout = config.request_timeout_secs;
|
|
|
|
if config.backend == "nearai" || config.backend == "near_ai" || config.backend == "near" {
|
|
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()
|
|
.ok_or_else(|| LlmError::AuthFailed {
|
|
provider: config.backend.clone(),
|
|
})?;
|
|
|
|
create_registry_provider(reg_config)
|
|
}
|
|
|
|
/// Create an LLM provider from a `NearAiConfig` directly.
|
|
///
|
|
/// This is useful when constructing additional providers for failover,
|
|
/// where only the model name differs from the primary config.
|
|
pub fn create_llm_provider_with_config(
|
|
config: &NearAiConfig,
|
|
session: Arc<SessionManager>,
|
|
request_timeout_secs: u64,
|
|
) -> Result<Arc<dyn LlmProvider>, LlmError> {
|
|
let auth_mode = if config.api_key.is_some() {
|
|
"API key"
|
|
} else {
|
|
"session token"
|
|
};
|
|
tracing::debug!(
|
|
model = %config.model,
|
|
base_url = %config.base_url,
|
|
auth = auth_mode,
|
|
timeout_secs = request_timeout_secs,
|
|
"Using NEAR AI (Chat Completions API)"
|
|
);
|
|
Ok(Arc::new(NearAiChatProvider::new_with_timeout(
|
|
config.clone(),
|
|
session,
|
|
request_timeout_secs,
|
|
)?))
|
|
}
|
|
|
|
/// Create a provider from a registry-resolved config.
|
|
///
|
|
/// Dispatches on `RegistryProviderConfig::protocol` to build the appropriate
|
|
/// rig-core client. This single function replaces what used to be 5 separate
|
|
/// `create_*_provider` functions.
|
|
fn create_registry_provider(
|
|
config: &RegistryProviderConfig,
|
|
) -> Result<Arc<dyn LlmProvider>, LlmError> {
|
|
match config.protocol {
|
|
ProviderProtocol::OpenAiCompletions => create_openai_compat_from_registry(config),
|
|
ProviderProtocol::Anthropic => create_anthropic_from_registry(config),
|
|
ProviderProtocol::Ollama => create_ollama_from_registry(config),
|
|
}
|
|
}
|
|
|
|
#[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::debug!(
|
|
"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> {
|
|
use rig::providers::openai;
|
|
|
|
let mut extra_headers = reqwest::header::HeaderMap::new();
|
|
for (key, value) in &config.extra_headers {
|
|
let name = match reqwest::header::HeaderName::from_bytes(key.as_bytes()) {
|
|
Ok(n) => n,
|
|
Err(e) => {
|
|
tracing::warn!(header = %key, error = %e, "Skipping extra header: invalid name");
|
|
continue;
|
|
}
|
|
};
|
|
let val = match reqwest::header::HeaderValue::from_str(value) {
|
|
Ok(v) => v,
|
|
Err(e) => {
|
|
tracing::warn!(header = %key, error = %e, "Skipping extra header: invalid value");
|
|
continue;
|
|
}
|
|
};
|
|
extra_headers.insert(name, val);
|
|
}
|
|
|
|
let api_key = config
|
|
.api_key
|
|
.as_ref()
|
|
.map(|k| k.expose_secret().to_string())
|
|
.unwrap_or_else(|| {
|
|
tracing::warn!(
|
|
provider = %config.provider_id,
|
|
"No API key configured for {}. Requests will likely fail with 401. \
|
|
Check your .env or secrets store.",
|
|
config.provider_id,
|
|
);
|
|
"no-key".to_string()
|
|
});
|
|
|
|
let mut builder = openai::Client::builder().api_key(&api_key);
|
|
if !config.base_url.is_empty() {
|
|
builder = builder.base_url(&config.base_url);
|
|
}
|
|
if !extra_headers.is_empty() {
|
|
builder = builder.http_headers(extra_headers);
|
|
}
|
|
|
|
let client: openai::Client = builder.build().map_err(|e| LlmError::RequestFailed {
|
|
provider: config.provider_id.clone(),
|
|
reason: format!("Failed to create OpenAI-compatible client: {e}"),
|
|
})?;
|
|
|
|
// Use CompletionsClient (Chat Completions API) instead of the default
|
|
// Client (Responses API). The Responses API path in rig-core handles
|
|
// tool results differently, which breaks IronClaw's tool call flow.
|
|
let client = client.completions_api();
|
|
let model = client.completion_model(&config.model);
|
|
|
|
tracing::debug!(
|
|
provider = %config.provider_id,
|
|
model = %config.model,
|
|
base_url = %config.base_url,
|
|
"Using OpenAI-compatible provider"
|
|
);
|
|
|
|
let adapter = RigAdapter::new(model, &config.model)
|
|
.with_unsupported_params(config.unsupported_params.clone());
|
|
Ok(Arc::new(adapter))
|
|
}
|
|
|
|
fn create_anthropic_from_registry(
|
|
config: &RegistryProviderConfig,
|
|
) -> Result<Arc<dyn LlmProvider>, LlmError> {
|
|
// Route to OAuth provider when an OAuth token is present and no real API
|
|
// key was provided. When both are set, the API key takes priority (standard
|
|
// x-api-key auth via rig-core).
|
|
let api_key_is_placeholder = config
|
|
.api_key
|
|
.as_ref()
|
|
.is_some_and(|k| k.expose_secret() == crate::llm::config::OAUTH_PLACEHOLDER);
|
|
if config.oauth_token.is_some() && (config.api_key.is_none() || api_key_is_placeholder) {
|
|
tracing::debug!(
|
|
provider = %config.provider_id,
|
|
model = %config.model,
|
|
base_url = if config.base_url.is_empty() { "default" } else { &config.base_url },
|
|
"Using Anthropic OAuth API"
|
|
);
|
|
let provider = anthropic_oauth::AnthropicOAuthProvider::new(config)?;
|
|
return Ok(Arc::new(provider));
|
|
}
|
|
|
|
use crate::llm::config::CacheRetention;
|
|
use rig::providers::anthropic;
|
|
|
|
let api_key = config
|
|
.api_key
|
|
.as_ref()
|
|
.map(|k| k.expose_secret().to_string())
|
|
.ok_or_else(|| LlmError::AuthFailed {
|
|
provider: config.provider_id.clone(),
|
|
})?;
|
|
|
|
let client: anthropic::Client = if config.base_url.is_empty() {
|
|
anthropic::Client::new(&api_key)
|
|
} else {
|
|
anthropic::Client::builder()
|
|
.api_key(&api_key)
|
|
.base_url(&config.base_url)
|
|
.build()
|
|
}
|
|
.map_err(|e| LlmError::RequestFailed {
|
|
provider: config.provider_id.clone(),
|
|
reason: format!("Failed to create Anthropic client: {e}"),
|
|
})?;
|
|
|
|
let cache_retention = config.cache_retention;
|
|
|
|
let model = client.completion_model(&config.model);
|
|
|
|
if cache_retention != CacheRetention::None {
|
|
tracing::debug!(
|
|
model = %config.model,
|
|
retention = %cache_retention,
|
|
"Anthropic automatic prompt caching enabled"
|
|
);
|
|
}
|
|
|
|
tracing::debug!(
|
|
provider = %config.provider_id,
|
|
model = %config.model,
|
|
base_url = if config.base_url.is_empty() { "default" } else { &config.base_url },
|
|
"Using Anthropic provider"
|
|
);
|
|
|
|
Ok(Arc::new(
|
|
RigAdapter::new(model, &config.model)
|
|
.with_cache_retention(cache_retention)
|
|
.with_unsupported_params(config.unsupported_params.clone()),
|
|
))
|
|
}
|
|
|
|
fn create_ollama_from_registry(
|
|
config: &RegistryProviderConfig,
|
|
) -> Result<Arc<dyn LlmProvider>, LlmError> {
|
|
use rig::client::Nothing;
|
|
use rig::providers::ollama;
|
|
|
|
let client: ollama::Client = ollama::Client::builder()
|
|
.base_url(&config.base_url)
|
|
.api_key(Nothing)
|
|
.build()
|
|
.map_err(|e| LlmError::RequestFailed {
|
|
provider: config.provider_id.clone(),
|
|
reason: format!("Failed to create Ollama client: {e}"),
|
|
})?;
|
|
|
|
let model = client.completion_model(&config.model);
|
|
|
|
tracing::debug!(
|
|
provider = %config.provider_id,
|
|
model = %config.model,
|
|
base_url = %config.base_url,
|
|
"Using Ollama provider"
|
|
);
|
|
|
|
let adapter = RigAdapter::new(model, &config.model)
|
|
.with_unsupported_params(config.unsupported_params.clone());
|
|
Ok(Arc::new(adapter))
|
|
}
|
|
|
|
/// Create a cheap/fast LLM provider for lightweight tasks (heartbeat, routing, evaluation).
|
|
///
|
|
/// Uses `NEARAI_CHEAP_MODEL` if set, otherwise falls back to the main provider.
|
|
/// Currently only supports NEAR AI backend.
|
|
pub fn create_cheap_llm_provider(
|
|
config: &LlmConfig,
|
|
session: Arc<SessionManager>,
|
|
) -> Result<Option<Arc<dyn LlmProvider>>, LlmError> {
|
|
let Some(ref cheap_model) = config.nearai.cheap_model else {
|
|
return Ok(None);
|
|
};
|
|
|
|
if config.backend != "nearai" {
|
|
tracing::warn!(
|
|
"NEARAI_CHEAP_MODEL is set but LLM_BACKEND is '{}', not nearai. \
|
|
Cheap model setting will be ignored.",
|
|
config.backend
|
|
);
|
|
return Ok(None);
|
|
}
|
|
|
|
let mut cheap_config = config.nearai.clone();
|
|
cheap_config.model = cheap_model.clone();
|
|
|
|
Ok(Some(Arc::new(NearAiChatProvider::new(
|
|
cheap_config,
|
|
session,
|
|
)?)))
|
|
}
|
|
|
|
/// Build the full LLM provider chain with all configured wrappers.
|
|
///
|
|
/// Applies decorators in this order:
|
|
/// 1. Raw provider (from config)
|
|
/// 2. RetryProvider (per-provider retry with exponential backoff)
|
|
/// 3. SmartRoutingProvider (cheap/primary split when cheap model is configured)
|
|
/// 4. FailoverProvider (fallback model when primary fails)
|
|
/// 5. CircuitBreakerProvider (fast-fail when backend is degraded)
|
|
/// 6. CachedProvider (in-memory response cache)
|
|
///
|
|
/// Also returns a separate cheap LLM provider for heartbeat/evaluation (not
|
|
/// part of the chain — it's a standalone provider for explicitly cheap tasks).
|
|
///
|
|
/// This is the single source of truth for provider chain construction,
|
|
/// called by both `main.rs` and `app.rs`.
|
|
#[allow(clippy::type_complexity)]
|
|
pub async fn build_provider_chain(
|
|
config: &LlmConfig,
|
|
session: Arc<SessionManager>,
|
|
) -> Result<
|
|
(
|
|
Arc<dyn LlmProvider>,
|
|
Option<Arc<dyn LlmProvider>>,
|
|
Option<Arc<RecordingLlm>>,
|
|
),
|
|
LlmError,
|
|
> {
|
|
let llm = create_llm_provider(config, session.clone()).await?;
|
|
tracing::debug!("LLM provider initialized: {}", llm.model_name());
|
|
|
|
// 1. Retry
|
|
let retry_config = RetryConfig {
|
|
max_retries: config.nearai.max_retries,
|
|
};
|
|
let llm: Arc<dyn LlmProvider> = if retry_config.max_retries > 0 {
|
|
tracing::debug!(
|
|
max_retries = retry_config.max_retries,
|
|
"LLM retry wrapper enabled"
|
|
);
|
|
Arc::new(RetryProvider::new(llm, retry_config.clone()))
|
|
} else {
|
|
llm
|
|
};
|
|
|
|
// 2. Smart routing (cheap/primary split)
|
|
let llm: Arc<dyn LlmProvider> = if let Some(ref cheap_model) = config.nearai.cheap_model {
|
|
let mut cheap_config = config.nearai.clone();
|
|
cheap_config.model = cheap_model.clone();
|
|
let cheap = create_llm_provider_with_config(
|
|
&cheap_config,
|
|
session.clone(),
|
|
config.request_timeout_secs,
|
|
)?;
|
|
let cheap: Arc<dyn LlmProvider> = if retry_config.max_retries > 0 {
|
|
Arc::new(RetryProvider::new(cheap, retry_config.clone()))
|
|
} else {
|
|
cheap
|
|
};
|
|
tracing::debug!(
|
|
primary = %llm.model_name(),
|
|
cheap = %cheap.model_name(),
|
|
"Smart routing enabled"
|
|
);
|
|
Arc::new(SmartRoutingProvider::new(
|
|
llm,
|
|
cheap,
|
|
SmartRoutingConfig {
|
|
cascade_enabled: config.nearai.smart_routing_cascade,
|
|
..SmartRoutingConfig::default()
|
|
},
|
|
))
|
|
} else {
|
|
llm
|
|
};
|
|
|
|
// 3. Failover
|
|
let llm: Arc<dyn LlmProvider> = if let Some(ref fallback_model) = config.nearai.fallback_model {
|
|
if fallback_model == &config.nearai.model {
|
|
tracing::warn!(
|
|
"fallback_model is the same as primary model, failover may not be effective"
|
|
);
|
|
}
|
|
let mut fallback_config = config.nearai.clone();
|
|
fallback_config.model = fallback_model.clone();
|
|
let fallback = create_llm_provider_with_config(
|
|
&fallback_config,
|
|
session.clone(),
|
|
config.request_timeout_secs,
|
|
)?;
|
|
tracing::debug!(
|
|
primary = %llm.model_name(),
|
|
fallback = %fallback.model_name(),
|
|
"LLM failover enabled"
|
|
);
|
|
let fallback: Arc<dyn LlmProvider> = if retry_config.max_retries > 0 {
|
|
Arc::new(RetryProvider::new(fallback, retry_config.clone()))
|
|
} else {
|
|
fallback
|
|
};
|
|
let cooldown_config = CooldownConfig {
|
|
cooldown_duration: std::time::Duration::from_secs(config.nearai.failover_cooldown_secs),
|
|
failure_threshold: config.nearai.failover_cooldown_threshold,
|
|
};
|
|
Arc::new(FailoverProvider::with_cooldown(
|
|
vec![llm, fallback],
|
|
cooldown_config,
|
|
)?)
|
|
} else {
|
|
llm
|
|
};
|
|
|
|
// 4. Circuit breaker
|
|
let llm: Arc<dyn LlmProvider> = if let Some(threshold) = config.nearai.circuit_breaker_threshold
|
|
{
|
|
let cb_config = CircuitBreakerConfig {
|
|
failure_threshold: threshold,
|
|
recovery_timeout: std::time::Duration::from_secs(
|
|
config.nearai.circuit_breaker_recovery_secs,
|
|
),
|
|
..CircuitBreakerConfig::default()
|
|
};
|
|
tracing::debug!(
|
|
threshold,
|
|
recovery_secs = config.nearai.circuit_breaker_recovery_secs,
|
|
"LLM circuit breaker enabled"
|
|
);
|
|
Arc::new(CircuitBreakerProvider::new(llm, cb_config))
|
|
} else {
|
|
llm
|
|
};
|
|
|
|
// 5. Response cache
|
|
let llm: Arc<dyn LlmProvider> = if config.nearai.response_cache_enabled {
|
|
let rc_config = ResponseCacheConfig {
|
|
ttl: std::time::Duration::from_secs(config.nearai.response_cache_ttl_secs),
|
|
max_entries: config.nearai.response_cache_max_entries,
|
|
};
|
|
tracing::debug!(
|
|
ttl_secs = config.nearai.response_cache_ttl_secs,
|
|
max_entries = config.nearai.response_cache_max_entries,
|
|
"LLM response cache enabled"
|
|
);
|
|
Arc::new(CachedProvider::new(llm, rc_config))
|
|
} else {
|
|
llm
|
|
};
|
|
|
|
// 6. Recording (trace capture for replay testing)
|
|
let recording_handle = RecordingLlm::from_env(llm.clone());
|
|
let llm: Arc<dyn LlmProvider> = if let Some(ref recorder) = recording_handle {
|
|
Arc::clone(recorder) as Arc<dyn LlmProvider>
|
|
} else {
|
|
llm
|
|
};
|
|
|
|
// Standalone cheap LLM for heartbeat/evaluation (not part of the chain)
|
|
let cheap_llm = create_cheap_llm_provider(config, session)?;
|
|
if let Some(ref cheap) = cheap_llm {
|
|
tracing::debug!("Cheap LLM provider initialized: {}", cheap.model_name());
|
|
}
|
|
|
|
Ok((llm, cheap_llm, recording_handle))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::llm::config::NearAiConfig;
|
|
|
|
fn test_nearai_config() -> NearAiConfig {
|
|
NearAiConfig {
|
|
model: "test-model".to_string(),
|
|
cheap_model: None,
|
|
base_url: "https://api.near.ai".to_string(),
|
|
api_key: None,
|
|
fallback_model: None,
|
|
max_retries: 3,
|
|
circuit_breaker_threshold: None,
|
|
circuit_breaker_recovery_secs: 30,
|
|
response_cache_enabled: false,
|
|
response_cache_ttl_secs: 3600,
|
|
response_cache_max_entries: 1000,
|
|
failover_cooldown_secs: 300,
|
|
failover_cooldown_threshold: 3,
|
|
smart_routing_cascade: true,
|
|
}
|
|
}
|
|
|
|
fn test_llm_config() -> LlmConfig {
|
|
LlmConfig {
|
|
backend: "nearai".to_string(),
|
|
session: SessionConfig::default(),
|
|
nearai: test_nearai_config(),
|
|
provider: None,
|
|
bedrock: None,
|
|
request_timeout_secs: 120,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_create_cheap_llm_provider_returns_none_when_not_configured() {
|
|
let config = test_llm_config();
|
|
let session = Arc::new(SessionManager::new(SessionConfig::default()));
|
|
|
|
let result = create_cheap_llm_provider(&config, session);
|
|
assert!(result.is_ok());
|
|
assert!(result.unwrap().is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_create_cheap_llm_provider_creates_provider_when_configured() {
|
|
let mut config = test_llm_config();
|
|
config.nearai.cheap_model = Some("cheap-test-model".to_string());
|
|
|
|
let session = Arc::new(SessionManager::new(SessionConfig::default()));
|
|
let result = create_cheap_llm_provider(&config, session);
|
|
|
|
assert!(result.is_ok());
|
|
let provider = result.unwrap();
|
|
assert!(provider.is_some());
|
|
assert_eq!(provider.unwrap().model_name(), "cheap-test-model");
|
|
}
|
|
|
|
#[test]
|
|
fn test_create_cheap_llm_provider_ignored_for_non_nearai_backend() {
|
|
let mut config = test_llm_config();
|
|
config.backend = "openai".to_string();
|
|
config.nearai.cheap_model = Some("cheap-test-model".to_string());
|
|
|
|
let session = Arc::new(SessionManager::new(SessionConfig::default()));
|
|
let result = create_cheap_llm_provider(&config, session);
|
|
|
|
assert!(result.is_ok());
|
|
assert!(result.unwrap().is_none());
|
|
}
|
|
}
|