Files
optimclaw/src/llm/circuit_breaker.rs
T
448383cfb0 refactor: remove Responses API, consolidate to Chat Completions (#272)
* fix: strip reasoning from LLM responses and persist assistant messages reliably

- Filter out `type: "reasoning"` output items from NEAR AI Responses API
  parsing so chain-of-thought never reaches the UI (nearai.rs)
- Rewrite clean_response with regex-based tag stripping that is
  code-aware (preserves tags inside fenced blocks and inline backticks),
  supports 9+ tag names (think, thought, reasoning, reflection, etc.),
  handles <final> extraction, pipe-delimited tags, and case/whitespace
  tolerance (reasoning.rs)
- Add Reasoning::complete() helper so all non-agentic LLM call sites
  (summarize, suggest, heartbeat, compaction) get automatic response
  cleaning; thread SafetyLayer through to those callers
- Change persist_turn from fire-and-forget tokio::spawn to awaited async
  so both user and assistant messages are written before returning,
  preventing data loss on shutdown/restart
- Pass input_count through seed_response_chain so response chaining
  delta calculation is accurate after thread hydration on restart
- Make NearAiResponse.usage optional and preserve response_id in alt
  response path for chaining continuity
- Persist session token to DB during onboarding wizard so runtime
  loads it without legacy-key fallback; suppress spurious warning on
  fresh installs
- Fix dev tool double-registration when builder already registers them
- Load dotenv/ironclaw env for doctor and status subcommands
- Reduce startup log noise (demote info→debug for skills, remove
  redundant info lines)

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* Nudge to not loop over tools continuesly

* refactor: remove Responses API, consolidate NEAR AI to Chat Completions only

The Responses API provider (nearai.rs, 1278 lines) added significant complexity
(response chaining state machine, delta message calculation, previous_response_id
persistence) for marginal benefit. This consolidates to the Chat Completions API
only, upgrading NearAiChatProvider with dual auth (session token + API key) and
401 retry for session token renewal.

- Delete src/llm/nearai.rs (Responses API provider)
- Upgrade nearai_chat.rs with SessionManager, dual auth, flexible list_models
- Remove response_id from CompletionResponse and ToolCompletionResponse
- Remove seed_response_chain/get_response_chain_id from LlmProvider trait
- Remove response chain persistence from agent (thread_ops, session)
- Remove NearAiApiMode enum and NEARAI_API_MODE config
- Clean up all wrapper providers (retry, circuit_breaker, failover, cache)
- Update documentation (CLAUDE.md, .env.example)

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* feat: runtime log level control via gateway UI and URL parameter

Add server-side log level switching using tracing_subscriber::reload::Layer
so the EnvFilter can be swapped at runtime without restarting. Expose via
GET/PUT /api/logs/level endpoints, a "Server: LEVEL" dropdown in the logs
toolbar, and a ?log_level=debug URL parameter for one-click activation.

Also applies cargo fmt to pre-existing files (llm/, tests/).

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-02-20 20:43:32 +00:00

571 lines
19 KiB
Rust

//! Circuit breaker for LLM providers.
//!
//! Wraps any `LlmProvider` with a state machine that trips open after
//! consecutive transient failures, preventing request storms against a
//! degraded backend. Automatically probes for recovery via half-open state.
//!
//! ```text
//! Closed ──(failures >= threshold)──► Open
//! ▲ │
//! │ (recovery timeout)
//! │ ▼
//! └──(probe succeeds)──── HalfOpen ──(probe fails)──► Open
//! ```
use std::sync::Arc;
use std::time::{Duration, Instant};
use async_trait::async_trait;
use rust_decimal::Decimal;
use tokio::sync::Mutex;
use crate::error::LlmError;
use crate::llm::provider::{
CompletionRequest, CompletionResponse, LlmProvider, ModelMetadata, ToolCompletionRequest,
ToolCompletionResponse,
};
/// Configuration for the circuit breaker.
#[derive(Debug, Clone)]
pub struct CircuitBreakerConfig {
/// Consecutive transient failures before the circuit opens.
pub failure_threshold: u32,
/// How long the circuit stays open before allowing a probe.
pub recovery_timeout: Duration,
/// Successful probes needed in half-open to close the circuit.
pub half_open_successes_needed: u32,
}
impl Default for CircuitBreakerConfig {
fn default() -> Self {
Self {
failure_threshold: 5,
recovery_timeout: Duration::from_secs(30),
half_open_successes_needed: 2,
}
}
}
/// Circuit breaker states.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CircuitState {
/// Normal operation; tracking consecutive failures.
Closed,
/// Rejecting all calls; waiting for recovery timeout to elapse.
Open,
/// Allowing probe calls to test whether the backend recovered.
HalfOpen,
}
/// Internal mutable state.
struct BreakerState {
state: CircuitState,
consecutive_failures: u32,
opened_at: Option<Instant>,
half_open_successes: u32,
}
impl BreakerState {
fn new() -> Self {
Self {
state: CircuitState::Closed,
consecutive_failures: 0,
opened_at: None,
half_open_successes: 0,
}
}
}
/// Wraps an `LlmProvider` with circuit breaker protection.
///
/// Tracks consecutive transient failures. After `failure_threshold` failures
/// the circuit opens and all requests are rejected for `recovery_timeout`.
/// After that timeout a probe call is allowed through (half-open); if it
/// succeeds the circuit closes, otherwise it reopens.
pub struct CircuitBreakerProvider {
inner: Arc<dyn LlmProvider>,
state: Mutex<BreakerState>,
config: CircuitBreakerConfig,
}
impl CircuitBreakerProvider {
pub fn new(inner: Arc<dyn LlmProvider>, config: CircuitBreakerConfig) -> Self {
Self {
inner,
state: Mutex::new(BreakerState::new()),
config,
}
}
/// Current circuit state (for observability / health checks).
pub async fn circuit_state(&self) -> CircuitState {
self.state.lock().await.state
}
/// Number of consecutive failures recorded so far.
pub async fn consecutive_failures(&self) -> u32 {
self.state.lock().await.consecutive_failures
}
/// Pre-flight: is a call allowed right now?
async fn check_allowed(&self) -> Result<(), LlmError> {
let mut state = self.state.lock().await;
match state.state {
CircuitState::Closed | CircuitState::HalfOpen => Ok(()),
CircuitState::Open => {
if let Some(opened_at) = state.opened_at {
if opened_at.elapsed() >= self.config.recovery_timeout {
state.state = CircuitState::HalfOpen;
state.half_open_successes = 0;
tracing::info!(
provider = self.inner.model_name(),
"Circuit breaker: Open -> HalfOpen, allowing probe"
);
Ok(())
} else {
let remaining = self
.config
.recovery_timeout
.checked_sub(opened_at.elapsed())
.unwrap_or(Duration::ZERO);
Err(LlmError::RequestFailed {
provider: self.inner.model_name().to_string(),
reason: format!(
"Circuit breaker open ({} consecutive failures, \
recovery in {:.0}s)",
state.consecutive_failures,
remaining.as_secs_f64()
),
})
}
} else {
// opened_at should always be Some when Open; recover gracefully
state.state = CircuitState::Closed;
Ok(())
}
}
}
}
/// Record a successful call.
async fn record_success(&self) {
let mut state = self.state.lock().await;
match state.state {
CircuitState::Closed => {
state.consecutive_failures = 0;
}
CircuitState::HalfOpen => {
state.half_open_successes += 1;
if state.half_open_successes >= self.config.half_open_successes_needed {
state.state = CircuitState::Closed;
state.consecutive_failures = 0;
state.opened_at = None;
tracing::info!(
provider = self.inner.model_name(),
"Circuit breaker: HalfOpen -> Closed (recovered)"
);
}
}
CircuitState::Open => {
// Shouldn't get here (check_allowed blocks Open), but recover
state.state = CircuitState::Closed;
state.consecutive_failures = 0;
state.opened_at = None;
}
}
}
/// Record a failed call; only transient errors count toward the threshold.
async fn record_failure(&self, err: &LlmError) {
if !is_transient(err) {
return;
}
let mut state = self.state.lock().await;
match state.state {
CircuitState::Closed => {
state.consecutive_failures += 1;
if state.consecutive_failures >= self.config.failure_threshold {
state.state = CircuitState::Open;
state.opened_at = Some(Instant::now());
tracing::warn!(
provider = self.inner.model_name(),
failures = state.consecutive_failures,
"Circuit breaker: Closed -> Open"
);
}
}
CircuitState::HalfOpen => {
state.state = CircuitState::Open;
state.opened_at = Some(Instant::now());
state.half_open_successes = 0;
tracing::warn!(
provider = self.inner.model_name(),
"Circuit breaker: HalfOpen -> Open (probe failed)"
);
}
CircuitState::Open => {}
}
}
}
/// Returns `true` for errors that indicate the provider is degraded
/// (server errors, rate limits, network failures, auth infrastructure down).
///
/// This answers: "should this error count toward tripping the circuit breaker?"
///
/// Includes `SessionExpired` because repeated session failures signal backend
/// auth infrastructure trouble.
///
/// Excludes client errors that are the caller's problem, not backend trouble:
/// `AuthFailed`, `ContextLengthExceeded`, `ModelNotAvailable`, `Json`.
///
/// See also `retry::is_retryable()` which answers a different question:
/// "could retrying this exact request succeed?"
fn is_transient(err: &LlmError) -> bool {
matches!(
err,
LlmError::RequestFailed { .. }
| LlmError::RateLimited { .. }
| LlmError::InvalidResponse { .. }
| LlmError::SessionExpired { .. }
| LlmError::SessionRenewalFailed { .. }
| LlmError::Http(_)
| LlmError::Io(_)
)
}
#[async_trait]
impl LlmProvider for CircuitBreakerProvider {
fn model_name(&self) -> &str {
self.inner.model_name()
}
fn cost_per_token(&self) -> (Decimal, Decimal) {
self.inner.cost_per_token()
}
async fn complete(&self, request: CompletionRequest) -> Result<CompletionResponse, LlmError> {
self.check_allowed().await?;
match self.inner.complete(request).await {
Ok(resp) => {
self.record_success().await;
Ok(resp)
}
Err(err) => {
self.record_failure(&err).await;
Err(err)
}
}
}
async fn complete_with_tools(
&self,
request: ToolCompletionRequest,
) -> Result<ToolCompletionResponse, LlmError> {
self.check_allowed().await?;
match self.inner.complete_with_tools(request).await {
Ok(resp) => {
self.record_success().await;
Ok(resp)
}
Err(err) => {
self.record_failure(&err).await;
Err(err)
}
}
}
async fn list_models(&self) -> Result<Vec<String>, LlmError> {
self.inner.list_models().await
}
async fn model_metadata(&self) -> Result<ModelMetadata, LlmError> {
self.inner.model_metadata().await
}
fn effective_model_name(&self, requested_model: Option<&str>) -> String {
self.inner.effective_model_name(requested_model)
}
fn active_model_name(&self) -> String {
self.inner.active_model_name()
}
fn set_model(&self, model: &str) -> Result<(), LlmError> {
self.inner.set_model(model)
}
fn calculate_cost(&self, input_tokens: u32, output_tokens: u32) -> Decimal {
self.inner.calculate_cost(input_tokens, output_tokens)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::StubLlm;
fn make_request() -> CompletionRequest {
CompletionRequest::new(vec![crate::llm::ChatMessage::user("hello")])
}
fn make_tool_request() -> ToolCompletionRequest {
ToolCompletionRequest::new(vec![crate::llm::ChatMessage::user("hello")], vec![])
}
fn fast_config(threshold: u32) -> CircuitBreakerConfig {
CircuitBreakerConfig {
failure_threshold: threshold,
recovery_timeout: Duration::from_millis(50),
half_open_successes_needed: 1,
}
}
// -- State machine tests --
#[tokio::test]
async fn closed_allows_calls_and_resets_on_success() {
let stub = Arc::new(StubLlm::new("ok").with_model_name("test"));
let cb = CircuitBreakerProvider::new(stub, fast_config(3));
let resp = cb.complete(make_request()).await;
assert!(resp.is_ok());
assert_eq!(cb.circuit_state().await, CircuitState::Closed);
assert_eq!(cb.consecutive_failures().await, 0);
}
#[tokio::test]
async fn failures_accumulate_then_trip_to_open() {
let stub = Arc::new(StubLlm::failing("test"));
let cb = CircuitBreakerProvider::new(stub, fast_config(3));
// First 2 failures: still closed
for i in 0..2 {
let _ = cb.complete(make_request()).await;
assert_eq!(cb.circuit_state().await, CircuitState::Closed);
assert_eq!(cb.consecutive_failures().await, i + 1);
}
// 3rd failure: trips to open
let _ = cb.complete(make_request()).await;
assert_eq!(cb.circuit_state().await, CircuitState::Open);
}
#[tokio::test]
async fn open_rejects_immediately() {
let stub = Arc::new(StubLlm::failing("test"));
let cb = CircuitBreakerProvider::new(
stub,
CircuitBreakerConfig {
failure_threshold: 1,
recovery_timeout: Duration::from_secs(60),
half_open_successes_needed: 1,
},
);
// Trip the breaker
let _ = cb.complete(make_request()).await;
assert_eq!(cb.circuit_state().await, CircuitState::Open);
// Next call should fail with circuit breaker message
let err = cb.complete(make_request()).await.unwrap_err();
match err {
LlmError::RequestFailed { reason, .. } => {
assert!(
reason.contains("Circuit breaker open"),
"Expected circuit breaker message, got: {}",
reason
);
}
other => panic!("Expected RequestFailed, got: {:?}", other),
}
}
#[tokio::test]
async fn recovery_timeout_transitions_to_half_open() {
let stub = Arc::new(StubLlm::failing("test"));
let cb = CircuitBreakerProvider::new(stub, fast_config(1));
// Trip to open
let _ = cb.complete(make_request()).await;
assert_eq!(cb.circuit_state().await, CircuitState::Open);
// Wait for recovery timeout
tokio::time::sleep(Duration::from_millis(60)).await;
// Next call should transition to half-open (and fail, since stub fails)
let _ = cb.complete(make_request()).await;
// Failed probe sends it back to Open
assert_eq!(cb.circuit_state().await, CircuitState::Open);
}
#[tokio::test]
async fn half_open_success_closes_circuit() {
let stub = Arc::new(StubLlm::failing("test"));
let cb = CircuitBreakerProvider::new(stub.clone(), fast_config(1));
// Trip to open
let _ = cb.complete(make_request()).await;
assert_eq!(cb.circuit_state().await, CircuitState::Open);
// Wait for recovery, then make the stub succeed
tokio::time::sleep(Duration::from_millis(60)).await;
stub.set_failing(false);
// Probe should succeed, closing the circuit
let resp = cb.complete(make_request()).await;
assert!(resp.is_ok());
assert_eq!(cb.circuit_state().await, CircuitState::Closed);
assert_eq!(cb.consecutive_failures().await, 0);
}
#[tokio::test]
async fn half_open_failure_reopens_circuit() {
let stub = Arc::new(StubLlm::failing("test"));
let cb = CircuitBreakerProvider::new(stub, fast_config(1));
// Trip to open
let _ = cb.complete(make_request()).await;
// Wait for recovery timeout
tokio::time::sleep(Duration::from_millis(60)).await;
// Probe fails (stub still failing)
let _ = cb.complete(make_request()).await;
assert_eq!(cb.circuit_state().await, CircuitState::Open);
}
#[tokio::test]
async fn non_transient_errors_do_not_trip_breaker() {
let stub = Arc::new(StubLlm::failing_non_transient("test"));
let cb = CircuitBreakerProvider::new(stub, fast_config(1));
// ContextLengthExceeded is not transient; breaker should stay closed
for _ in 0..5 {
let _ = cb.complete(make_request()).await;
}
assert_eq!(cb.circuit_state().await, CircuitState::Closed);
assert_eq!(cb.consecutive_failures().await, 0);
}
#[tokio::test]
async fn success_resets_failure_count() {
let stub = Arc::new(StubLlm::failing("test"));
let cb = CircuitBreakerProvider::new(stub.clone(), fast_config(3));
// Accumulate 2 failures
let _ = cb.complete(make_request()).await;
let _ = cb.complete(make_request()).await;
assert_eq!(cb.consecutive_failures().await, 2);
// One success resets the counter
stub.set_failing(false);
let resp = cb.complete(make_request()).await;
assert!(resp.is_ok());
assert_eq!(cb.consecutive_failures().await, 0);
}
#[tokio::test]
async fn complete_with_tools_uses_same_breaker_logic() {
let stub = Arc::new(StubLlm::failing("test"));
let cb = CircuitBreakerProvider::new(stub, fast_config(2));
let _ = cb.complete_with_tools(make_tool_request()).await;
let _ = cb.complete_with_tools(make_tool_request()).await;
assert_eq!(cb.circuit_state().await, CircuitState::Open);
}
#[tokio::test]
async fn multiple_half_open_successes_needed() {
let stub = Arc::new(StubLlm::failing("test"));
let cb = CircuitBreakerProvider::new(
stub.clone(),
CircuitBreakerConfig {
failure_threshold: 1,
recovery_timeout: Duration::from_millis(50),
half_open_successes_needed: 3,
},
);
// Trip to open
let _ = cb.complete(make_request()).await;
// Wait and flip to succeed
tokio::time::sleep(Duration::from_millis(60)).await;
stub.set_failing(false);
// First probe: half-open, success but not enough yet
let _ = cb.complete(make_request()).await;
assert_eq!(cb.circuit_state().await, CircuitState::HalfOpen);
// Second probe: still half-open
let _ = cb.complete(make_request()).await;
assert_eq!(cb.circuit_state().await, CircuitState::HalfOpen);
// Third probe: closes
let _ = cb.complete(make_request()).await;
assert_eq!(cb.circuit_state().await, CircuitState::Closed);
}
// -- Error classification tests --
#[test]
fn transient_classification() {
// Transient
assert!(is_transient(&LlmError::RequestFailed {
provider: "p".into(),
reason: "err".into(),
}));
assert!(is_transient(&LlmError::RateLimited {
provider: "p".into(),
retry_after: None,
}));
assert!(is_transient(&LlmError::InvalidResponse {
provider: "p".into(),
reason: "bad".into(),
}));
assert!(is_transient(&LlmError::SessionExpired {
provider: "p".into(),
}));
assert!(is_transient(&LlmError::SessionRenewalFailed {
provider: "p".into(),
reason: "timeout".into(),
}));
assert!(is_transient(&LlmError::Io(std::io::Error::new(
std::io::ErrorKind::ConnectionReset,
"reset"
))));
// NOT transient
assert!(!is_transient(&LlmError::AuthFailed {
provider: "p".into(),
}));
assert!(!is_transient(&LlmError::ContextLengthExceeded {
used: 100_000,
limit: 50_000,
}));
assert!(!is_transient(&LlmError::ModelNotAvailable {
provider: "p".into(),
model: "m".into(),
}));
assert!(!is_transient(&LlmError::Json(
serde_json::from_str::<String>("bad").unwrap_err()
)));
}
// -- Passthrough delegation tests --
#[tokio::test]
async fn passthrough_methods_delegate_to_inner() {
let stub = Arc::new(StubLlm::new("ok").with_model_name("my-model"));
let cb = CircuitBreakerProvider::new(stub, fast_config(3));
assert_eq!(cb.model_name(), "my-model");
assert_eq!(cb.active_model_name(), "my-model");
assert_eq!(cb.cost_per_token(), (Decimal::ZERO, Decimal::ZERO));
assert_eq!(cb.calculate_cost(100, 50), Decimal::ZERO);
}
}