mirror of
https://github.com/whit3rabbit/anyllm-proxy.git
synced 2026-09-21 16:00:49 +00:00
LiteLLM parity features: - Response caching (in-memory moka, optional Redis tier) with per-request TTL - Batch API (file upload, job creation/listing via OpenAI delegation) - Per-key budget enforcement (daily/monthly/lifetime) with lazy period reset - RBAC (admin/developer key roles, developer keys blocked from /admin/) - Audio transcription/speech and image generation passthrough - Fallback chain config (YAML-based, 5xx/429 failover) - Cost tracking foundation (model pricing DB, per-key spend queries) Code quality cleanup (simplify pass): - Extract try_cache_response helper (was copy-pasted 4x) - Extract common_routes for batch/models (was duplicated across 3 HandlerMode arms) - Deduplicate embeddings_passthrough to delegate to raw_passthrough - Remove dead code: inject_cost_header, BudgetDuration::from_str_lossy, duplicate CacheConfig - Collapse epoch_to_ymd wrapper into pub(crate) days_to_ymd Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
184 lines
5.1 KiB
Rust
184 lines
5.1 KiB
Rust
// Integration tests for the fallback chain module.
|
|
// Tests the FallbackChain logic, should_fallback predicate, and config parsing.
|
|
|
|
use anyllm_proxy::fallback::config::{parse_fallback_config, BackendSpec};
|
|
use anyllm_proxy::fallback::{FallbackChain, FallbackOutcome, FALLBACK_EXHAUSTED_HEADER};
|
|
|
|
/// Helper: create a `BackendError::OpenAI(ApiError { .. })` with a given status code.
|
|
fn make_api_error(status: u16) -> anyllm_proxy::backend::BackendError {
|
|
use anyllm_translate::openai::errors::{ErrorDetail, ErrorResponse};
|
|
anyllm_proxy::backend::BackendError::OpenAI(
|
|
anyllm_proxy::backend::openai_client::OpenAIClientError::ApiError {
|
|
status,
|
|
error: ErrorResponse {
|
|
error: ErrorDetail {
|
|
message: format!("mock error {status}"),
|
|
error_type: "test".to_string(),
|
|
param: None,
|
|
code: None,
|
|
},
|
|
},
|
|
},
|
|
)
|
|
}
|
|
|
|
// -- Config parsing tests --
|
|
|
|
#[test]
|
|
fn config_roundtrip() {
|
|
let yaml = r#"
|
|
fallback_chains:
|
|
default:
|
|
- name: azure
|
|
env_prefix: AZURE_FB_
|
|
- name: openai
|
|
env_prefix: OPENAI_FB_
|
|
"#;
|
|
let config = parse_fallback_config(yaml).unwrap();
|
|
let chain = &config.fallback_chains["default"];
|
|
assert_eq!(chain.len(), 2);
|
|
assert_eq!(chain[0].name, "azure");
|
|
assert_eq!(chain[1].env_prefix, "OPENAI_FB_");
|
|
}
|
|
|
|
#[test]
|
|
fn config_empty_chains() {
|
|
let yaml = "fallback_chains: {}\n";
|
|
let config = parse_fallback_config(yaml).unwrap();
|
|
assert!(config.fallback_chains.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn config_malformed_yaml_errors() {
|
|
let yaml = "not valid yaml: [[[";
|
|
assert!(parse_fallback_config(yaml).is_err());
|
|
}
|
|
|
|
// -- should_fallback predicate tests --
|
|
|
|
#[test]
|
|
fn should_fallback_server_errors() {
|
|
assert!(FallbackChain::should_fallback(500, false));
|
|
assert!(FallbackChain::should_fallback(502, false));
|
|
assert!(FallbackChain::should_fallback(503, false));
|
|
}
|
|
|
|
#[test]
|
|
fn should_fallback_rate_limit() {
|
|
assert!(FallbackChain::should_fallback(429, false));
|
|
}
|
|
|
|
#[test]
|
|
fn should_fallback_connection_error() {
|
|
assert!(FallbackChain::should_fallback(0, true));
|
|
}
|
|
|
|
#[test]
|
|
fn should_not_fallback_client_errors() {
|
|
assert!(!FallbackChain::should_fallback(400, false));
|
|
assert!(!FallbackChain::should_fallback(401, false));
|
|
assert!(!FallbackChain::should_fallback(403, false));
|
|
assert!(!FallbackChain::should_fallback(404, false));
|
|
}
|
|
|
|
// -- FallbackChain integration tests --
|
|
|
|
#[tokio::test]
|
|
async fn primary_503_falls_back_to_secondary() {
|
|
let chain = FallbackChain::new(vec![
|
|
BackendSpec {
|
|
name: "primary".into(),
|
|
env_prefix: "P_".into(),
|
|
},
|
|
BackendSpec {
|
|
name: "secondary".into(),
|
|
env_prefix: "S_".into(),
|
|
},
|
|
]);
|
|
|
|
let outcome = chain
|
|
.attempt_with_fallback(|spec, _| {
|
|
let name = spec.name.clone();
|
|
async move {
|
|
if name == "primary" {
|
|
Err(make_api_error(503))
|
|
} else {
|
|
Ok("ok from secondary")
|
|
}
|
|
}
|
|
})
|
|
.await;
|
|
|
|
assert!(outcome.result.is_ok());
|
|
assert_eq!(outcome.backend_name, "secondary");
|
|
assert_eq!(outcome.backend_index, 1);
|
|
assert!(!outcome.exhausted);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn primary_400_does_not_fallback() {
|
|
let chain = FallbackChain::new(vec![
|
|
BackendSpec {
|
|
name: "primary".into(),
|
|
env_prefix: "P_".into(),
|
|
},
|
|
BackendSpec {
|
|
name: "secondary".into(),
|
|
env_prefix: "S_".into(),
|
|
},
|
|
]);
|
|
|
|
let outcome: FallbackOutcome<&str> = chain
|
|
.attempt_with_fallback(|spec, _| {
|
|
let name = spec.name.clone();
|
|
async move {
|
|
if name == "primary" {
|
|
Err(make_api_error(400))
|
|
} else {
|
|
Ok("should not reach")
|
|
}
|
|
}
|
|
})
|
|
.await;
|
|
|
|
assert!(outcome.result.is_err());
|
|
assert_eq!(outcome.backend_name, "primary");
|
|
assert_eq!(outcome.backend_index, 0);
|
|
// Not exhausted because we stopped early on a non-retryable error.
|
|
assert!(!outcome.exhausted);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn all_backends_fail_sets_exhausted() {
|
|
let chain = FallbackChain::new(vec![
|
|
BackendSpec {
|
|
name: "a".into(),
|
|
env_prefix: "A_".into(),
|
|
},
|
|
BackendSpec {
|
|
name: "b".into(),
|
|
env_prefix: "B_".into(),
|
|
},
|
|
BackendSpec {
|
|
name: "c".into(),
|
|
env_prefix: "C_".into(),
|
|
},
|
|
]);
|
|
|
|
let outcome: FallbackOutcome<&str> = chain
|
|
.attempt_with_fallback(|_spec, _| async move { Err(make_api_error(502)) })
|
|
.await;
|
|
|
|
assert!(outcome.result.is_err());
|
|
assert!(outcome.exhausted);
|
|
// Last backend tried.
|
|
assert_eq!(outcome.backend_index, 2);
|
|
assert_eq!(outcome.backend_name, "c");
|
|
}
|
|
|
|
#[test]
|
|
fn fallback_exhausted_header_value() {
|
|
// Verify the header constant is what callers expect.
|
|
assert_eq!(FALLBACK_EXHAUSTED_HEADER, "x-anyllm-fallback-exhausted");
|
|
}
|