use async_trait::async_trait;
use tokio_stream::StreamExt;
use crate::claude_code::CREDENTIAL_ENV_KEYS as CLAUDE_KEYS;
use crate::codex_cli::CREDENTIAL_ENV_KEYS as CODEX_KEYS;
use crate::config::{CliRunnerType, RunnerConfig};
use crate::copilot::CREDENTIAL_ENV_KEYS as COPILOT_KEYS;
use crate::factory::create_runner_with_config;
use crate::gemini_cli::CREDENTIAL_ENV_KEYS as GEMINI_KEYS;
use crate::types::{
ChatRequest, ChatResponse, ChatStream, LlmCapabilities, LlmProvider, RunnerError,
};
#[must_use]
pub const fn credential_env_key(runner_type: CliRunnerType) -> Option<&'static str> {
match runner_type {
CliRunnerType::ClaudeCode => Some(CLAUDE_KEYS[0]),
CliRunnerType::Copilot => Some(COPILOT_KEYS[0]),
CliRunnerType::GeminiCli => Some(GEMINI_KEYS[0]),
CliRunnerType::CodexCli => Some(CODEX_KEYS[0]),
_ => None,
}
}
pub struct PooledTier {
inner: Box<dyn LlmProvider>,
name: &'static str,
display_name: String,
}
impl PooledTier {
#[must_use]
pub fn new(inner: Box<dyn LlmProvider>, position: usize) -> Self {
let name: &'static str = if position == 0 {
inner.name()
} else {
Box::leak(format!("{}#{}", inner.name(), position + 1).into_boxed_str())
};
let display_name = if position == 0 {
inner.display_name().to_owned()
} else {
format!("{} (account {})", inner.display_name(), position + 1)
};
Self {
inner,
name,
display_name,
}
}
}
#[async_trait]
impl LlmProvider for PooledTier {
fn name(&self) -> &'static str {
self.name
}
fn display_name(&self) -> &str {
&self.display_name
}
fn capabilities(&self) -> LlmCapabilities {
self.inner.capabilities()
}
fn default_model(&self) -> &str {
self.inner.default_model()
}
fn available_models(&self) -> &[String] {
self.inner.available_models()
}
async fn complete(&self, request: &ChatRequest) -> Result<ChatResponse, RunnerError> {
self.inner
.complete(request)
.await
.map_err(|e| self.rename(e))
}
async fn complete_stream(&self, request: &ChatRequest) -> Result<ChatStream, RunnerError> {
let stream = self
.inner
.complete_stream(request)
.await
.map_err(|e| self.rename(e))?;
let (runner, account) = (self.inner.name(), self.name);
Ok(Box::pin(stream.map(move |chunk| {
chunk.map_err(|e| renamed(e, runner, account))
})))
}
async fn health_check(&self) -> Result<bool, RunnerError> {
self.inner.health_check().await.map_err(|e| self.rename(e))
}
}
impl PooledTier {
fn rename(&self, error: RunnerError) -> RunnerError {
renamed(error, self.inner.name(), self.name)
}
}
fn renamed(mut error: RunnerError, runner: &str, account: &str) -> RunnerError {
if runner != account {
if let Some(rest) = error
.message
.strip_prefix(runner)
.and_then(|r| r.strip_prefix(": "))
{
error.message = format!("{account}: {rest}");
}
}
error
}
pub fn provider_pool(
accounts: Vec<Box<dyn LlmProvider>>,
) -> Result<Vec<Box<dyn LlmProvider>>, RunnerError> {
if accounts.is_empty() {
return Err(empty_pool());
}
Ok(accounts
.into_iter()
.enumerate()
.map(|(position, account)| {
Box::new(PooledTier::new(account, position)) as Box<dyn LlmProvider>
})
.collect())
}
fn empty_pool() -> RunnerError {
RunnerError::config("a credential pool needs at least one credential")
}
pub async fn cli_pool(
runner_type: CliRunnerType,
base: RunnerConfig,
credentials: &[String],
) -> Result<Vec<Box<dyn LlmProvider>>, RunnerError> {
if credentials.is_empty() {
return Err(empty_pool());
}
let Some(key) = credential_env_key(runner_type) else {
return Err(RunnerError::config(format!(
"{} reads no credential from its environment, so it cannot be pooled by one",
runner_type.binary_name()
)));
};
let mut accounts: Vec<Box<dyn LlmProvider>> = Vec::with_capacity(credentials.len());
for credential in credentials {
let config = base.clone().with_env(key, credential.as_str());
accounts.push(create_runner_with_config(runner_type, config).await?);
}
provider_pool(accounts)
}