use std::collections::HashMap;
use std::net::IpAddr;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use async_trait::async_trait;
use tokio::sync::mpsc;
use crate::config::LlmProviderConfig;
use crate::error::{ProviderError, Result};
use crate::openai_compat::OpenAiCompatProvider;
use crate::provider::Provider;
use crate::types::{ChatRequest, ChatResponse, StreamChunk};
pub const DEFAULT_TTL: Duration = Duration::from_secs(5);
pub trait PlacementResolver: Send + Sync {
fn resolve_base_url(&self, role: &str) -> Option<String>;
}
pub struct CachedResolver {
inner: Arc<dyn PlacementResolver>,
ttl: Duration,
cache: Mutex<HashMap<String, (Instant, Option<String>)>>,
}
impl CachedResolver {
pub fn new(inner: Arc<dyn PlacementResolver>, ttl: Duration) -> Self {
Self {
inner,
ttl,
cache: Mutex::new(HashMap::new()),
}
}
pub fn invalidate(&self, role: &str) {
if let Ok(mut c) = self.cache.lock() {
c.remove(role);
}
}
pub fn invalidate_all(&self) {
if let Ok(mut c) = self.cache.lock() {
c.clear();
}
}
}
impl PlacementResolver for CachedResolver {
fn resolve_base_url(&self, role: &str) -> Option<String> {
if let Ok(c) = self.cache.lock()
&& let Some((at, ans)) = c.get(role)
&& at.elapsed() < self.ttl
{
return ans.clone();
}
let ans = self.inner.resolve_base_url(role);
if let Ok(mut c) = self.cache.lock() {
c.insert(role.to_string(), (Instant::now(), ans.clone()));
}
ans
}
}
pub fn is_loopback_http(url: &str) -> bool {
let Some(rest) = url.strip_prefix("http://") else {
return false;
};
let authority = rest.split(['/', '?', '#']).next().unwrap_or("");
if authority.contains('@') {
return false;
}
let host = if let Some(h) = authority.strip_prefix('[') {
h.split(']').next().unwrap_or("")
} else {
authority.rsplit_once(':').map_or(authority, |(h, _)| h)
};
host == "localhost" || host.parse::<IpAddr>().is_ok_and(|ip| ip.is_loopback())
}
pub type ProviderFactory = Arc<dyn Fn(LlmProviderConfig) -> Arc<dyn Provider> + Send + Sync>;
fn openai_factory() -> ProviderFactory {
Arc::new(|cfg| Arc::new(OpenAiCompatProvider::new(cfg)))
}
pub struct PlacedProvider {
role: String,
config: LlmProviderConfig,
fallback: Arc<dyn Provider>,
factory: ProviderFactory,
resolver: Arc<CachedResolver>,
placed: Mutex<HashMap<String, Arc<dyn Provider>>>,
}
impl PlacedProvider {
pub fn new(role: String, config: LlmProviderConfig, resolver: Arc<CachedResolver>) -> Self {
Self::with_factory(role, config, resolver, openai_factory())
}
pub fn with_factory(
role: String,
config: LlmProviderConfig,
resolver: Arc<CachedResolver>,
factory: ProviderFactory,
) -> Self {
Self {
role,
fallback: factory(config.clone()),
factory,
config,
resolver,
placed: Mutex::new(HashMap::new()),
}
}
pub fn role(&self) -> &str {
&self.role
}
fn current(&self) -> (Option<String>, Option<Arc<dyn Provider>>) {
let Some(url) = self
.resolver
.resolve_base_url(&self.role)
.filter(|u| is_loopback_http(u))
else {
return (None, None);
};
let Ok(mut map) = self.placed.lock() else {
return (None, None);
};
let p = map
.entry(url.clone())
.or_insert_with(|| {
let mut cfg = self.config.clone();
cfg.base_url = url.clone();
(self.factory)(cfg)
})
.clone();
(Some(url), Some(p))
}
fn should_retry(err: &ProviderError) -> bool {
match err {
ProviderError::Connect(_) => true,
ProviderError::ServerError { status, .. } => matches!(status, 502..=504),
ProviderError::Http(e) => e.is_connect(),
_ => false,
}
}
}
#[async_trait]
impl Provider for PlacedProvider {
fn name(&self) -> &str {
&self.config.name
}
async fn complete(&self, request: &ChatRequest) -> Result<ChatResponse> {
let (url, placed) = self.current();
let Some(placed) = placed else {
return self.fallback.complete(request).await;
};
match placed.complete(request).await {
Err(e) if Self::should_retry(&e) => {
self.resolver.invalidate(&self.role);
if url.as_deref() == Some(self.config.base_url.trim_end_matches('/')) {
return Err(e);
}
self.fallback.complete(request).await
}
other => other,
}
}
async fn complete_stream(
&self,
request: &ChatRequest,
tx: mpsc::Sender<StreamChunk>,
) -> Result<()> {
let (_, placed) = self.current();
match placed {
Some(p) => {
let r = p.complete_stream(request, tx).await;
if matches!(&r, Err(e) if Self::should_retry(e)) {
self.resolver.invalidate(&self.role);
}
r
}
None => self.fallback.complete_stream(request, tx).await,
}
}
}
impl std::fmt::Debug for PlacedProvider {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PlacedProvider")
.field("role", &self.role)
.field("fallback_base_url", &self.config.base_url)
.finish()
}
}
#[cfg(test)]
#[path = "placement_tests.rs"]
mod tests;