use serde::{Deserialize, Serialize};
use crate::kernel_boundary::{ProposedContent, TraceLink};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BackendError {
InvalidRequest { message: String },
ExecutionFailed { message: String },
Unavailable { message: String },
BudgetExceeded { resource: String, limit: String },
ContractFailed { contract: String, message: String },
UnsupportedCapability { capability: BackendCapability },
AdapterError { message: String },
RecallError { message: String },
Timeout {
deadline_ms: u64,
elapsed_ms: u64,
},
CircuitOpen {
backend: String,
retry_after_ms: Option<u64>,
},
Retried {
message: String,
attempts: usize,
was_transient: bool,
},
Other { message: String },
}
impl std::fmt::Display for BackendError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidRequest { message } => write!(f, "Invalid request: {}", message),
Self::ExecutionFailed { message } => write!(f, "Execution failed: {}", message),
Self::Unavailable { message } => write!(f, "Backend unavailable: {}", message),
Self::BudgetExceeded { resource, limit } => {
write!(f, "Budget exceeded: {} (limit: {})", resource, limit)
}
Self::ContractFailed { contract, message } => {
write!(f, "Contract '{}' failed: {}", contract, message)
}
Self::UnsupportedCapability { capability } => {
write!(f, "Unsupported capability: {:?}", capability)
}
Self::AdapterError { message } => write!(f, "Adapter error: {}", message),
Self::RecallError { message } => write!(f, "Recall error: {}", message),
Self::Timeout {
deadline_ms,
elapsed_ms,
} => {
write!(
f,
"Operation timed out: elapsed {}ms, deadline {}ms",
elapsed_ms, deadline_ms
)
}
Self::CircuitOpen {
backend,
retry_after_ms,
} => {
if let Some(retry_after) = retry_after_ms {
write!(
f,
"Circuit breaker open for '{}', retry after {}ms",
backend, retry_after
)
} else {
write!(f, "Circuit breaker open for '{}'", backend)
}
}
Self::Retried {
message,
attempts,
was_transient,
} => {
write!(
f,
"Failed after {} attempts (transient: {}): {}",
attempts, was_transient, message
)
}
Self::Other { message } => write!(f, "{}", message),
}
}
}
impl std::error::Error for BackendError {}
impl BackendError {
#[must_use]
pub fn is_retryable(&self) -> bool {
match self {
Self::Timeout { .. } => true,
Self::Unavailable { .. } => true,
Self::ExecutionFailed { message } => {
let msg_lower = message.to_lowercase();
msg_lower.contains("timeout")
|| msg_lower.contains("rate limit")
|| msg_lower.contains("429")
|| msg_lower.contains("503")
|| msg_lower.contains("502")
|| msg_lower.contains("504")
|| msg_lower.contains("connection")
|| msg_lower.contains("network")
}
Self::RecallError { message } => {
let msg_lower = message.to_lowercase();
msg_lower.contains("timeout") || msg_lower.contains("unavailable")
}
Self::InvalidRequest { .. } => false,
Self::BudgetExceeded { .. } => false,
Self::ContractFailed { .. } => false,
Self::UnsupportedCapability { .. } => false,
Self::AdapterError { .. } => false,
Self::CircuitOpen { .. } => false, Self::Retried { .. } => false, Self::Other { .. } => false,
}
}
#[must_use]
pub fn is_overload(&self) -> bool {
match self {
Self::Unavailable { .. } => true,
Self::Timeout { .. } => true,
Self::ExecutionFailed { message } => {
let msg_lower = message.to_lowercase();
msg_lower.contains("rate limit")
|| msg_lower.contains("429")
|| msg_lower.contains("503")
|| msg_lower.contains("overloaded")
}
_ => false,
}
}
}
pub type BackendResult<T> = Result<T, BackendError>;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum BackendCapability {
Replay,
Adapters,
Recall,
StepContracts,
FrontierReasoning,
FastIteration,
Offline,
Streaming,
Vision,
ToolUse,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum BackoffStrategy {
Fixed,
Linear,
Exponential,
}
impl Default for BackoffStrategy {
fn default() -> Self {
Self::Exponential
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RetryPolicy {
pub max_attempts: usize,
pub initial_delay_ms: u64,
pub max_delay_ms: u64,
pub backoff: BackoffStrategy,
pub jitter_percent: u8,
}
impl Default for RetryPolicy {
fn default() -> Self {
Self {
max_attempts: 3,
initial_delay_ms: 100,
max_delay_ms: 10_000,
backoff: BackoffStrategy::Exponential,
jitter_percent: 20,
}
}
}
impl RetryPolicy {
#[must_use]
pub fn no_retry() -> Self {
Self {
max_attempts: 1,
..Default::default()
}
}
#[must_use]
pub fn aggressive() -> Self {
Self {
max_attempts: 5,
initial_delay_ms: 50,
max_delay_ms: 30_000,
backoff: BackoffStrategy::Exponential,
jitter_percent: 25,
}
}
#[must_use]
pub fn delay_for_attempt(&self, attempt: usize) -> u64 {
if attempt == 0 {
return 0;
}
let attempt = attempt.saturating_sub(1);
let delay = match self.backoff {
BackoffStrategy::Fixed => self.initial_delay_ms,
BackoffStrategy::Linear => self.initial_delay_ms.saturating_mul(attempt as u64 + 1),
BackoffStrategy::Exponential => self
.initial_delay_ms
.saturating_mul(1u64 << attempt.min(10)),
};
delay.min(self.max_delay_ms)
}
#[must_use]
pub fn should_retry(&self, attempt: usize) -> bool {
attempt < self.max_attempts
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CircuitBreakerConfig {
pub failure_threshold: usize,
pub success_threshold: usize,
pub timeout_ms: u64,
pub half_open_max_requests: usize,
}
impl Default for CircuitBreakerConfig {
fn default() -> Self {
Self {
failure_threshold: 5,
success_threshold: 2,
timeout_ms: 30_000,
half_open_max_requests: 3,
}
}
}
impl CircuitBreakerConfig {
#[must_use]
pub fn sensitive() -> Self {
Self {
failure_threshold: 3,
success_threshold: 1,
timeout_ms: 15_000,
half_open_max_requests: 1,
}
}
#[must_use]
pub fn tolerant() -> Self {
Self {
failure_threshold: 10,
success_threshold: 3,
timeout_ms: 60_000,
half_open_max_requests: 5,
}
}
#[must_use]
pub fn disabled() -> Self {
Self {
failure_threshold: usize::MAX,
success_threshold: 1,
timeout_ms: 0,
half_open_max_requests: usize::MAX,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum CircuitState {
#[default]
Closed,
Open,
HalfOpen,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BackendRequest {
pub intent_id: String,
pub truth_ids: Vec<String>,
pub prompt_version: String,
pub state_injection_hash: String,
pub prompt: BackendPrompt,
pub contracts: Vec<ContractSpec>,
pub budgets: BackendBudgets,
pub recall_policy: Option<BackendRecallPolicy>,
pub adapter_policy: Option<BackendAdapterPolicy>,
#[serde(default)]
pub retry_policy: Option<RetryPolicy>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum BackendPrompt {
Text(String),
Messages(Vec<Message>),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Message {
pub role: MessageRole,
pub content: String,
}
impl Message {
pub fn system(content: impl Into<String>) -> Self {
Self {
role: MessageRole::System,
content: content.into(),
}
}
pub fn user(content: impl Into<String>) -> Self {
Self {
role: MessageRole::User,
content: content.into(),
}
}
pub fn assistant(content: impl Into<String>) -> Self {
Self {
role: MessageRole::Assistant,
content: content.into(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum MessageRole {
System,
User,
Assistant,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContractSpec {
pub name: String,
pub schema: Option<serde_json::Value>,
pub required: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BackendBudgets {
pub max_tokens: usize,
pub max_iterations: usize,
pub latency_ceiling_ms: u64,
pub cost_ceiling_microdollars: u64,
}
impl Default for BackendBudgets {
fn default() -> Self {
Self {
max_tokens: 1024,
max_iterations: 1,
latency_ceiling_ms: 0,
cost_ceiling_microdollars: 0,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BackendRecallPolicy {
pub enabled: bool,
pub max_candidates: usize,
pub min_score: f32,
pub corpus_filter: Option<String>,
}
impl Default for BackendRecallPolicy {
fn default() -> Self {
Self {
enabled: false,
max_candidates: 5,
min_score: 0.5,
corpus_filter: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BackendAdapterPolicy {
pub adapter_id: Option<String>,
pub required: bool,
}
impl Default for BackendAdapterPolicy {
fn default() -> Self {
Self {
adapter_id: None,
required: false,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BackendResponse {
pub proposals: Vec<ProposedContent>,
pub contract_report: ContractReport,
pub trace_link: TraceLink,
pub usage: BackendUsage,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContractReport {
pub results: Vec<BackendContractResult>,
pub all_passed: bool,
}
impl ContractReport {
pub fn empty_pass() -> Self {
Self {
results: vec![],
all_passed: true,
}
}
pub fn from_results(results: Vec<BackendContractResult>) -> Self {
let all_passed = results.iter().all(|r| r.passed);
Self {
results,
all_passed,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BackendContractResult {
pub name: String,
pub passed: bool,
pub diagnostics: Option<String>,
}
impl BackendContractResult {
pub fn pass(name: impl Into<String>) -> Self {
Self {
name: name.into(),
passed: true,
diagnostics: None,
}
}
pub fn fail(name: impl Into<String>, diagnostics: impl Into<String>) -> Self {
Self {
name: name.into(),
passed: false,
diagnostics: Some(diagnostics.into()),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct BackendUsage {
pub input_tokens: usize,
pub output_tokens: usize,
pub total_tokens: usize,
pub latency_ms: u64,
pub cost_microdollars: Option<u64>,
}
#[deprecated(
since = "0.2.0",
note = "Use converge_core::traits::LlmBackend (GAT async) instead. See BOUNDARY.md for migration."
)]
pub trait LlmBackend: Send + Sync {
fn name(&self) -> &str;
fn supports_replay(&self) -> bool;
fn execute(&self, request: &BackendRequest) -> BackendResult<BackendResponse>;
fn supports_capability(&self, capability: BackendCapability) -> bool;
fn capabilities(&self) -> Vec<BackendCapability> {
let all_caps = [
BackendCapability::Replay,
BackendCapability::Adapters,
BackendCapability::Recall,
BackendCapability::StepContracts,
BackendCapability::FrontierReasoning,
BackendCapability::FastIteration,
BackendCapability::Offline,
BackendCapability::Streaming,
BackendCapability::Vision,
BackendCapability::ToolUse,
];
all_caps
.iter()
.filter(|cap| self.supports_capability(**cap))
.copied()
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_backend_budgets_default() {
let budgets = BackendBudgets::default();
assert_eq!(budgets.max_tokens, 1024);
assert_eq!(budgets.max_iterations, 1);
assert_eq!(budgets.latency_ceiling_ms, 0);
assert_eq!(budgets.cost_ceiling_microdollars, 0);
}
#[test]
fn test_message_constructors() {
let system = Message::system("You are a helpful assistant");
assert_eq!(system.role, MessageRole::System);
assert_eq!(system.content, "You are a helpful assistant");
let user = Message::user("Hello");
assert_eq!(user.role, MessageRole::User);
let assistant = Message::assistant("Hi there!");
assert_eq!(assistant.role, MessageRole::Assistant);
}
#[test]
fn test_contract_report_from_results() {
let results = vec![
BackendContractResult::pass("contract1"),
BackendContractResult::pass("contract2"),
];
let report = ContractReport::from_results(results);
assert!(report.all_passed);
let mixed = vec![
BackendContractResult::pass("contract1"),
BackendContractResult::fail("contract2", "missing field"),
];
let report = ContractReport::from_results(mixed);
assert!(!report.all_passed);
}
#[test]
fn test_backend_error_display() {
let err = BackendError::BudgetExceeded {
resource: "tokens".to_string(),
limit: "1024".to_string(),
};
assert!(err.to_string().contains("tokens"));
assert!(err.to_string().contains("1024"));
}
#[test]
fn test_capability_serialization_stable() {
assert_eq!(
serde_json::to_string(&BackendCapability::Replay).unwrap(),
"\"Replay\""
);
assert_eq!(
serde_json::to_string(&BackendCapability::FrontierReasoning).unwrap(),
"\"FrontierReasoning\""
);
}
#[test]
fn test_message_role_serialization_stable() {
assert_eq!(
serde_json::to_string(&MessageRole::System).unwrap(),
"\"System\""
);
assert_eq!(
serde_json::to_string(&MessageRole::User).unwrap(),
"\"User\""
);
assert_eq!(
serde_json::to_string(&MessageRole::Assistant).unwrap(),
"\"Assistant\""
);
}
#[test]
fn test_retry_policy_default() {
let policy = RetryPolicy::default();
assert_eq!(policy.max_attempts, 3);
assert_eq!(policy.initial_delay_ms, 100);
assert_eq!(policy.backoff, BackoffStrategy::Exponential);
}
#[test]
fn test_retry_policy_no_retry() {
let policy = RetryPolicy::no_retry();
assert_eq!(policy.max_attempts, 1);
assert!(!policy.should_retry(1));
}
#[test]
fn test_retry_policy_delay_exponential() {
let policy = RetryPolicy {
max_attempts: 5,
initial_delay_ms: 100,
max_delay_ms: 10_000,
backoff: BackoffStrategy::Exponential,
jitter_percent: 0,
};
assert_eq!(policy.delay_for_attempt(1), 100); assert_eq!(policy.delay_for_attempt(2), 200); assert_eq!(policy.delay_for_attempt(3), 400); assert_eq!(policy.delay_for_attempt(4), 800); }
#[test]
fn test_retry_policy_delay_linear() {
let policy = RetryPolicy {
max_attempts: 5,
initial_delay_ms: 100,
max_delay_ms: 10_000,
backoff: BackoffStrategy::Linear,
jitter_percent: 0,
};
assert_eq!(policy.delay_for_attempt(1), 100); assert_eq!(policy.delay_for_attempt(2), 200); assert_eq!(policy.delay_for_attempt(3), 300); }
#[test]
fn test_retry_policy_delay_fixed() {
let policy = RetryPolicy {
max_attempts: 5,
initial_delay_ms: 100,
max_delay_ms: 10_000,
backoff: BackoffStrategy::Fixed,
jitter_percent: 0,
};
assert_eq!(policy.delay_for_attempt(1), 100);
assert_eq!(policy.delay_for_attempt(2), 100);
assert_eq!(policy.delay_for_attempt(3), 100);
}
#[test]
fn test_retry_policy_max_delay_cap() {
let policy = RetryPolicy {
max_attempts: 20,
initial_delay_ms: 1000,
max_delay_ms: 5000,
backoff: BackoffStrategy::Exponential,
jitter_percent: 0,
};
assert_eq!(policy.delay_for_attempt(10), 5000);
}
#[test]
fn test_retry_policy_should_retry() {
let policy = RetryPolicy {
max_attempts: 3,
..Default::default()
};
assert!(policy.should_retry(1));
assert!(policy.should_retry(2));
assert!(!policy.should_retry(3));
assert!(!policy.should_retry(4));
}
#[test]
fn test_circuit_breaker_config_default() {
let config = CircuitBreakerConfig::default();
assert_eq!(config.failure_threshold, 5);
assert_eq!(config.success_threshold, 2);
assert_eq!(config.timeout_ms, 30_000);
}
#[test]
fn test_circuit_breaker_config_sensitive() {
let config = CircuitBreakerConfig::sensitive();
assert_eq!(config.failure_threshold, 3);
assert!(config.failure_threshold < CircuitBreakerConfig::default().failure_threshold);
}
#[test]
fn test_circuit_breaker_config_tolerant() {
let config = CircuitBreakerConfig::tolerant();
assert_eq!(config.failure_threshold, 10);
assert!(config.failure_threshold > CircuitBreakerConfig::default().failure_threshold);
}
#[test]
fn test_circuit_state_default() {
let state = CircuitState::default();
assert_eq!(state, CircuitState::Closed);
}
#[test]
fn test_timeout_is_retryable() {
let err = BackendError::Timeout {
deadline_ms: 5000,
elapsed_ms: 5001,
};
assert!(err.is_retryable());
assert!(err.is_overload());
}
#[test]
fn test_unavailable_is_retryable() {
let err = BackendError::Unavailable {
message: "Service temporarily unavailable".to_string(),
};
assert!(err.is_retryable());
assert!(err.is_overload());
}
#[test]
fn test_rate_limit_is_retryable() {
let err = BackendError::ExecutionFailed {
message: "Rate limit exceeded (429)".to_string(),
};
assert!(err.is_retryable());
assert!(err.is_overload());
}
#[test]
fn test_invalid_request_not_retryable() {
let err = BackendError::InvalidRequest {
message: "Missing required field".to_string(),
};
assert!(!err.is_retryable());
assert!(!err.is_overload());
}
#[test]
fn test_budget_exceeded_not_retryable() {
let err = BackendError::BudgetExceeded {
resource: "tokens".to_string(),
limit: "1024".to_string(),
};
assert!(!err.is_retryable());
assert!(!err.is_overload());
}
#[test]
fn test_circuit_open_not_retryable() {
let err = BackendError::CircuitOpen {
backend: "anthropic".to_string(),
retry_after_ms: Some(30_000),
};
assert!(!err.is_retryable());
assert!(!err.is_overload());
}
#[test]
fn test_timeout_error_display() {
let err = BackendError::Timeout {
deadline_ms: 5000,
elapsed_ms: 6000,
};
let msg = err.to_string();
assert!(msg.contains("6000"));
assert!(msg.contains("5000"));
}
#[test]
fn test_circuit_open_error_display() {
let err = BackendError::CircuitOpen {
backend: "test-backend".to_string(),
retry_after_ms: Some(30_000),
};
let msg = err.to_string();
assert!(msg.contains("test-backend"));
assert!(msg.contains("30000"));
}
#[test]
fn test_retried_error_display() {
let err = BackendError::Retried {
message: "Final error".to_string(),
attempts: 3,
was_transient: true,
};
let msg = err.to_string();
assert!(msg.contains("3 attempts"));
assert!(msg.contains("transient: true"));
}
#[test]
fn test_retry_policy_serialization_stable() {
let policy = RetryPolicy::default();
let json = serde_json::to_string(&policy).unwrap();
assert!(json.contains("\"max_attempts\":3"));
assert!(json.contains("\"Exponential\""));
let parsed: RetryPolicy = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, policy);
}
#[test]
fn test_circuit_breaker_config_serialization_stable() {
let config = CircuitBreakerConfig::default();
let json = serde_json::to_string(&config).unwrap();
assert!(json.contains("\"failure_threshold\":5"));
let parsed: CircuitBreakerConfig = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, config);
}
}