mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-26 07:30:11 +00:00
* refactor: extract shared assertion helpers to support/assertions.rs Move 5 assertion helpers from e2e_spot_checks.rs to a shared module. Add assert_all_tools_succeeded and assert_tool_succeeded for eliminating false positives in E2E tests. Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat: add tool output capture via tool_results() accessor Extract (name, preview) from ToolResult status events in TestChannel and TestRig, enabling content assertions on tool outputs. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: correct tool parameters in 3 broken trace fixtures - tool_time.json: add missing "operation": "now" for time tool - robust_correct_tool.json: same fix - memory_full_cycle.json: change "path" to "target" for memory_write Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: add tool success and output assertions to eliminate false positives Every E2E test that exercises tools now calls assert_all_tools_succeeded. Added tool output content assertions where tool results are predictable (time year, read_file content, memory_read content). Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat: capture per-tool timing from ToolStarted/ToolCompleted events Record Instant on ToolStarted and compute elapsed duration on ToolCompleted, wiring real timing data into collect_metrics() instead of hardcoded zeros. Co-Authored-By: Claude Opus 4.6 <[email protected]> * refactor: add RAII CleanupGuard for temp file/dir cleanup in tests Replace manual cleanup_test_dir() calls and inline remove_file() with Drop-based CleanupGuard that ensures cleanup even if a test panics. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: add Drop impl and graceful shutdown for TestRig Wrap agent_handle in Option so Drop can abort leaked tasks. Signal the channel shutdown before aborting for future cooperative shutdown. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: replace agent startup sleep with oneshot ready signal Use a oneshot channel fired in Channel::start() instead of a fixed 100ms sleep, eliminating the race condition on slow systems. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: replace fragile string-matching iteration limit with count-based detection Use tool completion count vs max_tool_iterations instead of scanning status messages for "iteration"/"limit" substrings. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: use assert_all_tools_succeeded for memory_full_cycle test Remove incorrect comment about memory_tree failing with empty path (it actually succeeds). Omit empty path from fixture and use the standard assert_all_tools_succeeded instead of per-tool assertions. Co-Authored-By: Claude Opus 4.6 <[email protected]> * refactor: promote benchmark metrics types to library code Move TraceMetrics, ScenarioResult, RunResult, MetricDelta, and compare_runs() from tests/support/metrics.rs to src/benchmark/metrics.rs. Existing tests use re-export for backward compatibility. Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat: add Scenario and Criterion types for agent benchmarking Scenario defines a task with input, success criteria, and resource limits. Criterion is an enum of programmatic checks (tool_used, response_contains, etc.) evaluated without LLM judgment. Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat: add initial benchmark scenario suite (12 scenarios across 5 categories) Scenarios cover tool_selection, tool_chaining, error_recovery, efficiency, and memory_operations. All loaded from JSON with deserialization validation test. Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat: add benchmark runner with BenchChannel and InstrumentedLlm BenchChannel is a minimal Channel implementation for benchmarks. InstrumentedLlm wraps any LlmProvider to capture per-call metrics. Runner creates a fresh agent per scenario, evaluates success criteria, and produces RunResult with timing, token, and cost metrics. Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat: add baseline management, reports, and benchmark entry point - baseline.rs: load/save/promote benchmark results - report.rs: format comparison reports with regression detection - benchmark_runner.rs: integration test with real LLM (feature-gated) - Add benchmark feature flag to Cargo.toml Co-Authored-By: Claude Opus 4.6 <[email protected]> * style: apply cargo fmt to benchmark module Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat(benchmark): add multi-turn scenario types with setup, judge, ResponseNotContains Add BenchScenario, Turn, TurnAssertions, JudgeConfig, ScenarioSetup, WorkspaceSetup, SeedDocument types for multi-turn benchmark scenarios. Add ResponseNotContains criterion variant. Add TurnAssertions::to_criteria() converter for backward compat with existing evaluation engine. Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat(benchmark): add JSON scenario loader with recursive discovery and tag filter Add load_bench_scenarios() for the new BenchScenario format with recursive directory traversal and tag-based filtering. Create 4 initial trajectory scenarios across tool-selection, multi-turn, and efficiency categories. Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat(benchmark): multi-turn runner with workspace seeding and per-turn metrics Add run_bench_scenario() that loops over BenchScenario turns, seeds workspace documents, collects per-turn metrics (tokens, tool calls, wall time), and evaluates per-turn assertions. Add TurnMetrics to metrics.rs and clear_for_next_turn() to BenchChannel. Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat(benchmark): add LLM-as-judge scoring with prompt formatting and score parsing Create judge.rs with format_judge_prompt, parse_judge_score, and judge_turn. Wire into run_bench_scenario for turns with judge config -- scores below min_score fail the turn. Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat(benchmark): add CLI subcommand (ironclaw benchmark) Add BenchmarkCommand with --tags, --scenario, --no-judge, --timeout, --update-baseline flags. Wire into Command enum and main.rs dispatch. Feature-gated behind benchmark flag. Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat(benchmark): per-scenario JSON output with full trajectory Add save_scenario_results() that writes per-scenario JSON files alongside the run summary. Each scenario gets its own file with turn_metrics trajectory. Update CLI to use new output format. Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat(benchmark): add ToolRegistry::retain_only and wire tool filtering in scenarios Add a retain_only() method to ToolRegistry that filters tools down to a given allowlist. Wire this into run_bench_scenario() so that when a scenario specifies a tools list in its setup, only those tools are available during the benchmark run. Includes two tests for the new method: one verifying filtering works and one verifying empty input is a no-op. Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat(benchmark): wire identity overrides into workspace before agent start Add seed_identity() helper that writes identity files (IDENTITY.md, USER.md, etc.) into the workspace before the agent starts, so that workspace.system_prompt() picks them up. Wire it into run_bench_scenario() after workspace seeding. Include a test that verifies identity files are written and readable. Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat(benchmark): add --parallel and --max-cost CLI flags Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix(benchmark): use feature-conditional snapshot names for CLI help tests Prevents snapshot conflicts between default (no benchmark) and all-features (with benchmark) builds by using separate snapshot names per feature set. Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat(benchmark): parallel execution with JoinSet and budget cap enforcement Replace sequential loop in run_all_bench() with parallel execution using JoinSet + semaphore when config.parallel > 1. Add budget cap enforcement that skips remaining scenarios when max_total_cost_usd is exceeded. Track skipped count in RunResult.skipped_scenarios and display it in format_report(). Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat(benchmark): add tool restriction and identity override test scenarios Co-Authored-By: Claude Opus 4.6 <[email protected]> * chore: fix formatting for Phase 3 Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat(benchmark): add SkillRegistry::retain_only and wire skill filtering in scenarios Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat(benchmark): add --json flag for machine-readable output Co-Authored-By: Claude Opus 4.6 <[email protected]> * ci: add GitHub Actions benchmark workflow (manual trigger) Co-Authored-By: Claude Opus 4.6 <[email protected]> * refactor(benchmark): remove in-tree benchmark harness, keep retain_only utilities Move benchmark-specific code out of ironclaw in preparation for the nearai/benchmarks trajectory adapter. This removes: - src/benchmark/ (runner, scenarios, metrics, judge, report, etc.) - src/cli/benchmark.rs and the Benchmark CLI subcommand - benchmarks/ data directory (scenarios + trajectories) - .github/workflows/benchmark.yml - The "benchmark" Cargo feature flag What remains: - ToolRegistry::retain_only() and SkillRegistry::retain_only() - Test support types (TraceMetrics, InstrumentedLlm) inlined into tests/support/ instead of re-exporting from the deleted module Co-Authored-By: Claude Opus 4.6 <[email protected]> * docs: add README for LLM trace fixture format Documents the trajectory JSON format, response types, request hints, directory structure, and how to write new traces. Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat(test): unify trace format around turns, add multi-turn support Introduce TraceTurn type that groups user_input with LLM response steps, making traces self-contained conversation trajectories. Add run_trace() to TestRig for automatic multi-turn replay. Backward-compatible: flat "steps" JSON is deserialized as a single turn transparently. Includes all trace fixtures (spot, coverage, advanced), plan docs, and new e2e tests for steering, error recovery, long chains, memory, and prompt injection resilience. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix(test): fix CI failures after merging main - Fix tool_json fixture: use "data" parameter (not "input") to match JsonTool schema - Fix status_events test: remove assertion for "time" tool that isn't in the fixture (only "echo" calls are used) - Allow dead_code in test support metrics/instrumented_llm modules (utilities for future benchmark tests) [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * Working on recording traces and testing them * feat(test): add declarative expects to trace fixtures, split infra tests Add TraceExpects struct with 9 optional assertion fields (response_contains, tools_used, all_tools_succeeded, etc.) that can be declared in fixture JSON instead of hand-written Rust. Add verify_expects() and run_recorded_trace() so recorded trace tests become one-liners. Split trace infra tests (deserialization, backward compat) into tests/trace_format.rs which doesn't require the libsql feature gate. Co-Authored-By: Claude Opus 4.6 <[email protected]> * refactor(test): add expects to all trace fixtures, simplify e2e tests Add declarative expects blocks to all 19 trace fixture JSONs across spot/, coverage/, advanced/, and root directories. Update all 8 e2e test files to use verify_trace_expects() / run_and_verify_trace(), replacing ~270 lines of hand-written assertions with fixture-driven verification. Tests that check things beyond expects (file content on disk, metrics, event ordering) keep those extra assertions alongside the declarative ones. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix(test): adapt tests to AppBuilder refactor, fix formatting Update test files to work with refactored TestRigBuilder that uses AppBuilder::build_all() (removing with_tools/with_workspace methods). Update telegram_check fixture to use tool_list instead of echo. Fix cargo fmt issues in src/llm/mod.rs and src/llm/recording.rs. Co-Authored-By: Claude Opus 4.6 <[email protected]> * refactor(test): deduplicate support unit tests into single binary Support modules (assertions, cleanup, test_channel, test_rig, trace_llm) had #[cfg(test)] mod tests blocks that were compiled and run 12 times — once per e2e test binary that declares `mod support;`. Extracted all 29 support unit tests into a dedicated `tests/support_unit_tests.rs` so they run exactly once. [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * style: fix trailing newlines in support files Co-Authored-By: Claude Opus 4.6 <[email protected]> * refactor(test): unify trace types and fix recorded multi-turn replay Import shared types (TraceStep, TraceResponse, TraceToolCall, RequestHint, ExpectedToolResult, MemorySnapshotEntry, HttpExchange*) from ironclaw::llm::recording instead of redefining them in trace_llm.rs. Fix the flat-steps deserializer to split at UserInput boundaries into multiple turns, instead of filtering them out and wrapping everything into a single turn. This enables recorded multi-turn traces to be replayed as proper multi-turn conversations via run_trace(). [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix(test): fix CI failures - unused imports and missing struct fields - Add #[allow(unused_imports)] on pub use re-exports in trace_llm.rs (types are re-exported for downstream test files, not used locally) - Add `..` to ToolCompleted pattern in test_channel.rs to match new `error` and `parameters` fields Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix(test): fix CI failures after merging main - Add missing `error` and `parameters` fields to ToolCompleted constructors in support_unit_tests.rs - Add `..` to ToolCompleted pattern match in support_unit_tests.rs - Add #[allow(dead_code)] to CleanupGuard, LlmTrace impl, and TraceLlm impl (only used behind #[cfg(feature = "libsql")]) Co-Authored-By: Claude Opus 4.6 <[email protected]> * Adding coverage running script * fix(test): address review feedback on E2E test infrastructure - Increase wait_for_responses polling to exponential backoff (50ms-500ms) and raise default timeout from 15s to 30s to reduce CI flakiness (#1) - Strengthen prompt_injection_resilience test with positive safety layer assertion via has_safety_warnings(), enable injection_check (#2) - Add assert_tool_order() helper and tools_order field in TraceExpects for verifying tool execution ordering in multi-step traces (#3) - Document TraceLlm sequential-call assumption for concurrency (#6) - Clean up CleanupGuard with PathKind enum instead of shotgun remove_file + remove_dir_all on every path (#8) - Fix coverage.sh: default to --lib only, fix multi-filter syntax, add COV_ALL_TARGETS option - Add coverage/ to .gitignore - Remove planning docs from PR [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address PR review - use HashSet in retain_only, improve skill test - Use HashSet for O(N+M) lookup in SkillRegistry::retain_only and ToolRegistry::retain_only instead of linear scan - Strengthen test_retain_only_empty_is_noop in SkillRegistry to pre-populate with a skill before asserting the no-op behavior [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix(test): revert incorrect safety layer assertion in injection test The safety layer sanitizes tool output, not user input. The injection test sends a malicious user message with no tools called, so the safety layer never fires. Reverted to the original test which correctly validates the LLM refuses via trace expects. Also fixed case-sensitive request hint ("ignore" -> "Ignore") to suppress noisy warning. [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: clean stale profdata before coverage run Adds `cargo llvm-cov clean` before each run to prevent "mismatched data" warnings from stale instrumentation profiles. [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * style: fix formatting in retain_only test [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 <[email protected]> Co-authored-by: Illia Polosukhin <[email protected]>
532 lines
18 KiB
Rust
532 lines
18 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
|
|
|
|
pub mod circuit_breaker;
|
|
pub mod costs;
|
|
pub mod failover;
|
|
mod nearai_chat;
|
|
mod provider;
|
|
mod reasoning;
|
|
pub mod recording;
|
|
pub mod response_cache;
|
|
pub mod retry;
|
|
mod rig_adapter;
|
|
pub mod session;
|
|
pub mod smart_routing;
|
|
|
|
pub use circuit_breaker::{CircuitBreakerConfig, CircuitBreakerProvider};
|
|
pub use failover::{CooldownConfig, FailoverProvider};
|
|
pub use nearai_chat::{ModelInfo, NearAiChatProvider};
|
|
pub use provider::{
|
|
ChatMessage, CompletionRequest, CompletionResponse, FinishReason, LlmProvider, ModelMetadata,
|
|
Role, ToolCall, ToolCompletionRequest, ToolCompletionResponse, ToolDefinition, ToolResult,
|
|
};
|
|
pub use reasoning::{
|
|
ActionPlan, Reasoning, ReasoningContext, RespondOutput, RespondResult, SILENT_REPLY_TOKEN,
|
|
TokenUsage, ToolSelection, is_silent_reply,
|
|
};
|
|
pub use recording::RecordingLlm;
|
|
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;
|
|
|
|
use crate::config::{LlmBackend, LlmConfig, NearAiConfig};
|
|
use crate::error::LlmError;
|
|
|
|
/// Create an LLM provider based on configuration.
|
|
///
|
|
/// - `NearAi` backend: Uses session manager for authentication (Responses API)
|
|
/// or API key (Chat Completions API)
|
|
/// - Other backends: Use rig-core adapter with provider-specific clients
|
|
pub fn create_llm_provider(
|
|
config: &LlmConfig,
|
|
session: Arc<SessionManager>,
|
|
) -> Result<Arc<dyn LlmProvider>, LlmError> {
|
|
match config.backend {
|
|
LlmBackend::NearAi => create_llm_provider_with_config(&config.nearai, session),
|
|
LlmBackend::OpenAi => create_openai_provider(config),
|
|
LlmBackend::Anthropic => create_anthropic_provider(config),
|
|
LlmBackend::Ollama => create_ollama_provider(config),
|
|
LlmBackend::OpenAiCompatible => create_openai_compatible_provider(config),
|
|
LlmBackend::Tinfoil => create_tinfoil_provider(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>,
|
|
) -> Result<Arc<dyn LlmProvider>, LlmError> {
|
|
let auth_mode = if config.api_key.is_some() {
|
|
"API key"
|
|
} else {
|
|
"session token"
|
|
};
|
|
tracing::info!(
|
|
model = %config.model,
|
|
base_url = %config.base_url,
|
|
auth = auth_mode,
|
|
"Using NEAR AI (Chat Completions API)"
|
|
);
|
|
Ok(Arc::new(NearAiChatProvider::new(config.clone(), session)?))
|
|
}
|
|
|
|
fn create_openai_provider(config: &LlmConfig) -> Result<Arc<dyn LlmProvider>, LlmError> {
|
|
let oai = config.openai.as_ref().ok_or_else(|| LlmError::AuthFailed {
|
|
provider: "openai".to_string(),
|
|
})?;
|
|
|
|
use rig::providers::openai;
|
|
|
|
// Use CompletionsClient (Chat Completions API) instead of the default Client
|
|
// (Responses API). The Responses API path in rig-core panics when tool results
|
|
// are sent back because ironclaw doesn't thread `call_id` through its ToolCall
|
|
// type. The Chat Completions API works correctly with the existing code.
|
|
let client: openai::CompletionsClient = if let Some(ref base_url) = oai.base_url {
|
|
tracing::info!(
|
|
"Using OpenAI direct API (chat completions, model: {}, base_url: {})",
|
|
oai.model,
|
|
base_url,
|
|
);
|
|
openai::Client::builder()
|
|
.base_url(base_url)
|
|
.api_key(oai.api_key.expose_secret())
|
|
.build()
|
|
} else {
|
|
tracing::info!(
|
|
"Using OpenAI direct API (chat completions, model: {}, base_url: default)",
|
|
oai.model,
|
|
);
|
|
openai::Client::new(oai.api_key.expose_secret())
|
|
}
|
|
.map_err(|e| LlmError::RequestFailed {
|
|
provider: "openai".to_string(),
|
|
reason: format!("Failed to create OpenAI client: {}", e),
|
|
})?
|
|
.completions_api();
|
|
|
|
let model = client.completion_model(&oai.model);
|
|
Ok(Arc::new(RigAdapter::new(model, &oai.model)))
|
|
}
|
|
|
|
fn create_anthropic_provider(config: &LlmConfig) -> Result<Arc<dyn LlmProvider>, LlmError> {
|
|
let anth = config
|
|
.anthropic
|
|
.as_ref()
|
|
.ok_or_else(|| LlmError::AuthFailed {
|
|
provider: "anthropic".to_string(),
|
|
})?;
|
|
|
|
use rig::providers::anthropic;
|
|
|
|
let client: anthropic::Client = if let Some(ref base_url) = anth.base_url {
|
|
anthropic::Client::builder()
|
|
.api_key(anth.api_key.expose_secret())
|
|
.base_url(base_url)
|
|
.build()
|
|
} else {
|
|
anthropic::Client::new(anth.api_key.expose_secret())
|
|
}
|
|
.map_err(|e| LlmError::RequestFailed {
|
|
provider: "anthropic".to_string(),
|
|
reason: format!("Failed to create Anthropic client: {}", e),
|
|
})?;
|
|
|
|
let model = client.completion_model(&anth.model);
|
|
tracing::info!(
|
|
"Using Anthropic direct API (model: {}, base_url: {})",
|
|
anth.model,
|
|
anth.base_url.as_deref().unwrap_or("default"),
|
|
);
|
|
Ok(Arc::new(RigAdapter::new(model, &anth.model)))
|
|
}
|
|
|
|
fn create_ollama_provider(config: &LlmConfig) -> Result<Arc<dyn LlmProvider>, LlmError> {
|
|
let oll = config.ollama.as_ref().ok_or_else(|| LlmError::AuthFailed {
|
|
provider: "ollama".to_string(),
|
|
})?;
|
|
|
|
use rig::client::Nothing;
|
|
use rig::providers::ollama;
|
|
|
|
let client: ollama::Client = ollama::Client::builder()
|
|
.base_url(&oll.base_url)
|
|
.api_key(Nothing)
|
|
.build()
|
|
.map_err(|e| LlmError::RequestFailed {
|
|
provider: "ollama".to_string(),
|
|
reason: format!("Failed to create Ollama client: {}", e),
|
|
})?;
|
|
|
|
let model = client.completion_model(&oll.model);
|
|
tracing::info!(
|
|
"Using Ollama (base_url: {}, model: {})",
|
|
oll.base_url,
|
|
oll.model
|
|
);
|
|
Ok(Arc::new(RigAdapter::new(model, &oll.model)))
|
|
}
|
|
|
|
const TINFOIL_BASE_URL: &str = "https://inference.tinfoil.sh/v1";
|
|
|
|
fn create_tinfoil_provider(config: &LlmConfig) -> Result<Arc<dyn LlmProvider>, LlmError> {
|
|
let tf = config
|
|
.tinfoil
|
|
.as_ref()
|
|
.ok_or_else(|| LlmError::AuthFailed {
|
|
provider: "tinfoil".to_string(),
|
|
})?;
|
|
|
|
use rig::providers::openai;
|
|
|
|
let client: openai::Client = openai::Client::builder()
|
|
.base_url(TINFOIL_BASE_URL)
|
|
.api_key(tf.api_key.expose_secret())
|
|
.build()
|
|
.map_err(|e| LlmError::RequestFailed {
|
|
provider: "tinfoil".to_string(),
|
|
reason: format!("Failed to create Tinfoil client: {}", e),
|
|
})?;
|
|
|
|
// Tinfoil currently only supports the Chat Completions API and not the newer Responses API,
|
|
// so we must explicitly select the completions API here (unlike other OpenAI-compatible providers).
|
|
let client = client.completions_api();
|
|
let model = client.completion_model(&tf.model);
|
|
tracing::info!("Using Tinfoil private inference (model: {})", tf.model);
|
|
Ok(Arc::new(RigAdapter::new(model, &tf.model)))
|
|
}
|
|
|
|
fn create_openai_compatible_provider(config: &LlmConfig) -> Result<Arc<dyn LlmProvider>, LlmError> {
|
|
let compat = config
|
|
.openai_compatible
|
|
.as_ref()
|
|
.ok_or_else(|| LlmError::AuthFailed {
|
|
provider: "openai_compatible".to_string(),
|
|
})?;
|
|
|
|
use rig::providers::openai;
|
|
|
|
let mut extra_headers = reqwest::header::HeaderMap::new();
|
|
for (key, value) in &compat.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 LLM_EXTRA_HEADERS entry: invalid header name");
|
|
continue;
|
|
}
|
|
};
|
|
let val = match reqwest::header::HeaderValue::from_str(value) {
|
|
Ok(v) => v,
|
|
Err(e) => {
|
|
tracing::warn!(header = %key, error = %e, "Skipping LLM_EXTRA_HEADERS entry: invalid header value");
|
|
continue;
|
|
}
|
|
};
|
|
extra_headers.insert(name, val);
|
|
}
|
|
|
|
let client: openai::CompletionsClient = openai::Client::builder()
|
|
.base_url(&compat.base_url)
|
|
.api_key(
|
|
compat
|
|
.api_key
|
|
.as_ref()
|
|
.map(|k| k.expose_secret().to_string())
|
|
.unwrap_or_else(|| "no-key".to_string()),
|
|
)
|
|
.http_headers(extra_headers)
|
|
.build()
|
|
.map_err(|e| LlmError::RequestFailed {
|
|
provider: "openai_compatible".to_string(),
|
|
reason: format!("Failed to create OpenAI-compatible client: {}", e),
|
|
})?
|
|
.completions_api();
|
|
|
|
let model = client.completion_model(&compat.model);
|
|
tracing::info!(
|
|
"Using OpenAI-compatible endpoint (chat completions, base_url: {}, model: {})",
|
|
compat.base_url,
|
|
compat.model
|
|
);
|
|
Ok(Arc::new(RigAdapter::new(model, &compat.model)))
|
|
}
|
|
|
|
/// 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 != LlmBackend::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 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())?;
|
|
tracing::info!("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::info!(
|
|
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())?;
|
|
let cheap: Arc<dyn LlmProvider> = if retry_config.max_retries > 0 {
|
|
Arc::new(RetryProvider::new(cheap, retry_config.clone()))
|
|
} else {
|
|
cheap
|
|
};
|
|
tracing::info!(
|
|
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())?;
|
|
tracing::info!(
|
|
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::info!(
|
|
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::info!(
|
|
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::info!("Cheap LLM provider initialized: {}", cheap.model_name());
|
|
}
|
|
|
|
Ok((llm, cheap_llm, recording_handle))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::config::{LlmBackend, NearAiConfig};
|
|
use std::path::PathBuf;
|
|
|
|
fn test_nearai_config() -> NearAiConfig {
|
|
NearAiConfig {
|
|
model: "test-model".to_string(),
|
|
cheap_model: None,
|
|
base_url: "https://api.near.ai".to_string(),
|
|
auth_base_url: "https://private.near.ai".to_string(),
|
|
session_path: PathBuf::from("/tmp/test-session.json"),
|
|
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: LlmBackend::NearAi,
|
|
nearai: test_nearai_config(),
|
|
openai: None,
|
|
anthropic: None,
|
|
ollama: None,
|
|
openai_compatible: None,
|
|
tinfoil: None,
|
|
}
|
|
}
|
|
|
|
#[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 = LlmBackend::OpenAi;
|
|
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());
|
|
}
|
|
}
|