use std::collections::HashMap;
use crate::agent::builder;
use crate::cli::Cli;
use crate::config::{Config, ProviderAuth, ProviderEntry};
use crate::context::ContextFiles;
#[cfg(feature = "mcp")]
use crate::extras::mcp::McpClientManager;
use crate::permission::ask::AskSender;
use crate::permission::checker::PermCheck;
use crate::sandbox::Sandbox;
#[cfg(feature = "semantic")]
use crate::semantic::SemanticManager;
use crate::agent::tools::plan::PlanSwitchSender;
use crate::agent::tools::question::QuestionSender;
use super::{
AnyAgent, AnyAgentInner, AnyClient, AnyModel, client, default_model_for_entry, summarize,
};
pub(crate) const ANTHROPIC_OAUTH_COMPACTION_DISABLED: &str = concat!(
"Anthropic OAuth is not used for compaction side-LLM calls; ",
"configure `summarization_provider` to a non-Anthropic-OAuth provider",
);
pub(crate) fn is_anthropic_oauth_compaction_disabled_error(err: &anyhow::Error) -> bool {
err.chain().any(|cause| {
cause
.to_string()
.contains(ANTHROPIC_OAUTH_COMPACTION_DISABLED)
})
}
fn openai_api_billing_fallback_key(cli: &Cli) -> Option<&str> {
cli.resolved_api_key
.as_deref()
.filter(|key| !key.is_empty())
.or_else(|| cli.api_key.as_deref().filter(|key| !key.is_empty()))
}
#[cfg(test)]
pub fn create_client(
provider_name: &str,
api_key: Option<&str>,
providers: &HashMap<String, ProviderEntry>,
) -> anyhow::Result<AnyClient> {
client::create_client(provider_name, api_key, providers)
}
pub fn create_client_with_auth(
provider_name: &str,
api_key: Option<&str>,
providers: &HashMap<String, ProviderEntry>,
default_auth: Option<crate::config::ProviderAuth>,
) -> anyhow::Result<AnyClient> {
client::create_client_with_auth(provider_name, api_key, providers, default_auth)
}
fn create_role_client(
provider_name: &str,
providers: &HashMap<String, ProviderEntry>,
default_auth: Option<ProviderAuth>,
) -> anyhow::Result<AnyClient> {
create_client_with_auth(provider_name, None, providers, default_auth)
}
#[allow(clippy::too_many_arguments)]
pub async fn build_agent(
model: AnyModel,
cli: &Cli,
cfg: &Config,
context: &ContextFiles,
permission: Option<PermCheck>,
ask_tx: Option<AskSender>,
question_tx: Option<QuestionSender>,
plan_tx: Option<PlanSwitchSender>,
bg_store: Option<crate::agent::tools::background::BackgroundStore>,
#[cfg(feature = "lsp")] lsp_manager: Option<std::sync::Arc<crate::lsp::manager::LspManager>>,
sandbox: Sandbox,
#[cfg(feature = "mcp")] mcp_manager: Option<&McpClientManager>,
#[cfg(feature = "semantic")] semantic_manager: Option<&SemanticManager>,
session_id: Option<String>,
) -> AnyAgent {
let parent_model = model.clone();
let provider_name = cli.resolve_provider(cfg);
let chunk_timeout = cfg.resolve_stream_chunk_timeout(&provider_name);
let model_name = parent_model.name();
let subagent_model = resolve_subagent_model(cfg);
let loop_task_model = subagent_model.unwrap_or_else(|| parent_model.clone());
macro_rules! build_inner {
($m:expr, $variant:ident) => {{
let permission_for_loop = permission.clone();
let ask_tx_for_loop = ask_tx.clone();
let question_tx_for_loop = question_tx.clone();
let plan_tx_for_loop = plan_tx.clone();
let bg_store_for_loop = bg_store.clone();
let sandbox_for_loop = sandbox.clone();
let parent_model_for_loop = Some(loop_task_model.clone());
#[cfg(feature = "lsp")]
let lsp_for_loop = lsp_manager.clone();
let (agent, cache, memory_provider) =
builder::build_agent_inner($m, cli, cfg, context, &provider_name, &model_name)
.await;
let (loop_tools, dyn_search, review_memory_tool) = builder::build_loop_tools(
cache.clone(),
permission_for_loop,
ask_tx_for_loop,
question_tx_for_loop,
plan_tx_for_loop,
bg_store_for_loop,
#[cfg(feature = "lsp")]
lsp_for_loop,
sandbox_for_loop,
parent_model_for_loop,
#[cfg(feature = "mcp")]
mcp_manager,
#[cfg(feature = "semantic")]
semantic_manager,
cli,
cfg,
session_id.clone(),
)
.await;
let mut preamble = agent.preamble.clone().unwrap_or_default();
if dyn_search.is_some() {
if !preamble.is_empty() {
preamble.push_str("\n\n");
}
preamble.push_str(crate::agent::prompt::DYNAMIC_TOOL_SEARCH_PROMPT);
}
let mut agent = AnyAgent::new(
AnyAgentInner::$variant(agent),
cache,
chunk_timeout,
loop_tools,
preamble,
model_name.clone(),
);
if let Some(provider) = memory_provider {
agent = agent.with_memory_provider(provider);
}
if let Some(tool) = review_memory_tool {
agent = agent.with_review_memory_tool(tool);
}
if let Some(ds) = dyn_search {
agent.with_dynamic_tool_search(ds.filter, ds.registry)
} else {
agent
}
}};
}
let mut agent = match model {
AnyModel::OpenRouter(m) => build_inner!(m, OpenRouter),
AnyModel::OpenAI(m) => build_inner!(m, OpenAI),
AnyModel::ChatGptOpenAI(m) => build_inner!(m, ChatGptOpenAI),
AnyModel::OpenAICodex(m) => build_inner!(m, OpenAICodex),
AnyModel::Anthropic(m) => build_inner!(m, Anthropic),
AnyModel::AnthropicOauth(m) => build_inner!(m, AnthropicOauth),
AnyModel::Gemini(m) => build_inner!(m, Gemini),
AnyModel::DeepSeek(m) => build_inner!(m, DeepSeek),
AnyModel::Glm(m) => build_inner!(m, Glm),
AnyModel::Ollama(m) => build_inner!(m, Ollama),
AnyModel::Custom(m) => build_inner!(m, Custom),
};
if matches!(parent_model, AnyModel::OpenAICodex(_)) {
match client::create_openai_api_key_fallback_client(
&provider_name,
openai_api_billing_fallback_key(cli),
&cfg.providers_map(),
) {
Ok(Some(fallback_client)) => {
let fallback_model = fallback_client.completion_model(model_name.clone());
agent = agent.with_openai_api_key_billing_fallback(fallback_model, ask_tx.clone());
tracing::info!(
target: "dirge::provider",
provider = %provider_name,
model = %model_name,
"OpenAI API-key billing fallback armed; requires user confirmation before use",
);
}
Ok(None) => {}
Err(err) => {
tracing::warn!(
target: "dirge::provider",
provider = %provider_name,
error = %err,
"failed to arm OpenAI API-key billing fallback",
);
}
}
}
{
let summarize_fn = build_summarize_fn(cfg, parent_model.clone());
agent = agent.with_summarizer(summarize_fn);
}
if cfg.escalation_provider.is_some() {
let default_role = cfg.resolve_role(crate::config::ConfigRole::Default);
let escalation_role = cfg.resolve_role(crate::config::ConfigRole::Escalation);
match (default_role, escalation_role) {
(Some((default_alias, _)), Some((escalation_alias, escalation_entry))) => {
if default_alias.eq_ignore_ascii_case(&escalation_alias) {
tracing::debug!(
target: "dirge::provider",
alias = %escalation_alias,
"escalation provider equals default; skipping duplicate client construction",
);
} else {
match build_escalation_stream_fn(
&escalation_alias,
&escalation_entry,
&cfg.providers_map(),
cfg.auth,
chunk_timeout,
agent.loop_tools(),
) {
Ok(stream_fn) => {
agent = agent.with_escalation(stream_fn, escalation_alias.clone());
tracing::info!(
target: "dirge::provider",
alias = %escalation_alias,
"dual-client escalation wired",
);
}
Err(e) => {
tracing::error!(
target: "dirge::provider",
alias = %escalation_alias,
error = %e,
"failed to construct escalation client; running without escalation",
);
eprintln!(
"warning: escalation_provider '{}' configured but client build failed: {}",
escalation_alias, e
);
}
}
}
}
(_, None) => {
let alias = cfg.escalation_provider.clone().unwrap_or_default();
tracing::error!(
target: "dirge::provider",
alias = %alias,
"escalation_provider configured but alias does not resolve to a known provider",
);
eprintln!(
"error: escalation_provider '{}' is configured but does not match any entry \
in `providers` or any built-in (anthropic/openai/deepseek/glm/gemini/ollama/openrouter). \
Either add it under `providers` or remove the `escalation_provider` setting.",
alias
);
}
(None, _) => {
}
}
}
if cfg.critic_provider.is_some() {
match cfg.resolve_role(crate::config::ConfigRole::Critic) {
Some((alias, entry)) => {
match build_critic_fn(&alias, &entry, &cfg.providers_map(), cfg.auth) {
Ok(critic_fn) => {
agent = agent.with_critic(critic_fn);
tracing::info!(target: "dirge::provider", alias = %alias, "in-loop critic wired");
}
Err(e) => {
tracing::error!(target: "dirge::provider", alias = %alias, error = %e, "failed to build critic client; running without critic");
eprintln!(
"warning: critic_provider '{alias}' configured but client build failed: {e}"
);
}
}
}
None => {
let alias = cfg.critic_provider.clone().unwrap_or_default();
eprintln!(
"error: critic_provider '{alias}' is configured but does not match any entry \
in `providers` or any built-in. Either add it under `providers` or remove \
the `critic_provider` setting."
);
}
}
}
if let Some(threshold) = cfg.resolve_context_depth_threshold() {
agent = agent.with_context_depth_reminder(threshold);
}
if let Some(store) = bg_store.as_ref() {
agent = agent.with_bg_store(store.clone());
}
if cfg.review_provider.is_some() {
let default_role = cfg.resolve_role(crate::config::ConfigRole::Default);
let review_role = cfg.resolve_role(crate::config::ConfigRole::Review);
match (default_role, review_role) {
(Some((default_alias, _)), Some((review_alias, review_entry))) => {
if default_alias.eq_ignore_ascii_case(&review_alias) {
tracing::debug!(
target: "dirge::provider",
alias = %review_alias,
"review provider equals default; skipping duplicate client construction",
);
} else {
match build_review_stream_fn(
&review_alias,
&review_entry,
&cfg.providers_map(),
cfg.auth,
chunk_timeout,
agent.loop_tools(),
) {
Ok((stream_fn, model_name)) => {
agent = agent.with_review_route(
stream_fn,
review_alias.clone(),
model_name,
);
tracing::info!(
target: "dirge::provider",
alias = %review_alias,
"review-provider route wired",
);
}
Err(e) => {
tracing::error!(
target: "dirge::provider",
alias = %review_alias,
"failed to build review stream_fn: {e}",
);
eprintln!(
"error: failed to build review stream_fn for '{}': {}",
review_alias, e
);
}
}
}
}
(_, None) => {
let alias = cfg.review_provider.as_deref().unwrap_or("(unset)");
tracing::warn!(
target: "dirge::provider",
alias = %alias,
"review_provider configured but alias does not resolve to a known provider",
);
eprintln!(
"error: review_provider '{}' is configured but does not match any entry \
in `providers` or any built-in. Either add it under `providers` or \
remove the `review_provider` setting.",
alias
);
}
(None, _) => {
}
}
}
agent = agent.with_max_turns(Some(cli.resolve_max_agent_turns(cfg)));
if cli.goal.as_deref().is_some_and(|g| !g.trim().is_empty())
&& cfg
.resolve_role(crate::config::ConfigRole::Critic)
.is_none()
{
tracing::warn!(
target: "dirge::goal",
"--goal is set but no critic_provider is configured to judge it; the goal gate will not fire",
);
}
agent = agent.with_goal(cli.goal.clone());
agent
}
fn build_escalation_stream_fn(
alias: &str,
entry: &ProviderEntry,
providers: &HashMap<String, ProviderEntry>,
default_auth: Option<ProviderAuth>,
chunk_timeout: std::time::Duration,
loop_tools: &[std::sync::Arc<dyn crate::agent::agent_loop::LoopTool>],
) -> anyhow::Result<crate::agent::agent_loop::StreamFn> {
use crate::agent::agent_loop::loop_tool_to_rig_definition;
let client = create_role_client(alias, providers, default_auth)?;
let model_name = entry
.model
.clone()
.unwrap_or_else(|| default_model_for_entry(alias, entry).to_string());
let model = client.completion_model(model_name);
let tool_defs: Vec<rig::completion::ToolDefinition> = loop_tools
.iter()
.map(|t| loop_tool_to_rig_definition(t.as_ref()))
.collect();
Ok(model.build_stream_fn(tool_defs, chunk_timeout, Some(alias.to_string())))
}
fn build_critic_fn(
alias: &str,
entry: &ProviderEntry,
providers: &HashMap<String, ProviderEntry>,
default_auth: Option<ProviderAuth>,
) -> anyhow::Result<crate::agent::agent_loop::critic::CriticFn> {
let client = std::sync::Arc::new(create_role_client(alias, providers, default_auth)?);
let model_name = entry
.model
.clone()
.unwrap_or_else(|| default_model_for_entry(alias, entry).to_string());
Ok(std::sync::Arc::new(move |prompt: String| {
let client = client.clone();
let model_name = model_name.clone();
Box::pin(async move {
let model = client.completion_model(model_name);
summarize::oneshot_with_model(
model,
"critic",
crate::agent::agent_loop::critic::CRITIC_PREAMBLE,
prompt,
)
.await
})
as std::pin::Pin<Box<dyn std::future::Future<Output = anyhow::Result<String>> + Send>>
}))
}
pub(crate) fn build_compaction_model(
cfg: &Config,
main_client: &AnyClient,
main_model_name: &str,
) -> anyhow::Result<AnyModel> {
if cfg.summarization_provider.is_some() {
let default_role = cfg.resolve_role(crate::config::ConfigRole::Default);
let summ_role = cfg.resolve_role(crate::config::ConfigRole::Summarization);
if let (Some((default_alias, _)), Some((alias, entry))) = (default_role, summ_role)
&& !default_alias.eq_ignore_ascii_case(&alias)
{
match create_role_client(&alias, &cfg.providers_map(), cfg.auth) {
Ok(client) => {
if matches!(&client, AnyClient::AnthropicOauth(_)) {
anyhow::bail!(ANTHROPIC_OAUTH_COMPACTION_DISABLED);
}
let model_name = entry
.model
.clone()
.unwrap_or_else(|| default_model_for_entry(&alias, &entry).to_string());
tracing::info!(
target: "dirge::provider",
alias = %alias,
"summarization_provider active for UI compaction",
);
return Ok(client.completion_model(model_name));
}
Err(e) => {
eprintln!(
"warning: summarization_provider '{alias}' failed to build ({e}); \
falling back to the main model for compaction if safe"
);
}
}
}
}
if matches!(main_client, AnyClient::AnthropicOauth(_)) {
anyhow::bail!(ANTHROPIC_OAUTH_COMPACTION_DISABLED);
}
Ok(main_client.completion_model(main_model_name.to_string()))
}
fn anthropic_oauth_compaction_disabled_fn() -> crate::agent::compression::SummarizeFn {
std::sync::Arc::new(|_prompt: String| {
Box::pin(async { anyhow::bail!(ANTHROPIC_OAUTH_COMPACTION_DISABLED) })
})
}
pub(crate) fn build_summarize_fn(
cfg: &Config,
main_model: AnyModel,
) -> crate::agent::compression::SummarizeFn {
let from_model = |model: AnyModel| -> crate::agent::compression::SummarizeFn {
std::sync::Arc::new(move |prompt: String| {
let m = model.clone();
Box::pin(async move { summarize::summarize_with_model(m, prompt).await })
})
};
if cfg.summarization_provider.is_some() {
let default_role = cfg.resolve_role(crate::config::ConfigRole::Default);
let summ_role = cfg.resolve_role(crate::config::ConfigRole::Summarization);
if let (Some((default_alias, _)), Some((alias, entry))) = (default_role, summ_role)
&& !default_alias.eq_ignore_ascii_case(&alias)
{
match create_role_client(&alias, &cfg.providers_map(), cfg.auth) {
Ok(client) => {
if matches!(&client, AnyClient::AnthropicOauth(_)) {
return anthropic_oauth_compaction_disabled_fn();
}
let model_name = entry
.model
.clone()
.unwrap_or_else(|| default_model_for_entry(&alias, &entry).to_string());
let model = client.completion_model(model_name);
tracing::info!(
target: "dirge::provider",
alias = %alias,
"summarization_provider active for in-loop compaction",
);
return from_model(model);
}
Err(e) => {
eprintln!(
"warning: summarization_provider '{alias}' failed to build ({e}); \
falling back to the main model for compaction if safe"
);
}
}
}
}
if matches!(&main_model, AnyModel::AnthropicOauth(_)) {
return anthropic_oauth_compaction_disabled_fn();
}
from_model(main_model)
}
fn resolve_subagent_model(cfg: &Config) -> Option<AnyModel> {
cfg.subagent_provider.as_ref()?;
let (default_alias, _) = cfg.resolve_role(crate::config::ConfigRole::Default)?;
let (alias, entry) = cfg.resolve_role(crate::config::ConfigRole::Subagent)?;
if default_alias.eq_ignore_ascii_case(&alias) {
return None;
}
match create_role_client(&alias, &cfg.providers_map(), cfg.auth) {
Ok(client) => {
let model_name = entry
.model
.clone()
.unwrap_or_else(|| default_model_for_entry(&alias, &entry).to_string());
tracing::info!(
target: "dirge::provider",
alias = %alias,
"subagent_provider active for task-spawned subagents",
);
Some(client.completion_model(model_name))
}
Err(e) => {
eprintln!(
"warning: subagent_provider '{alias}' failed to build ({e}); \
falling back to the main model for subagents"
);
None
}
}
}
pub fn build_approval_fn(
alias: &str,
entry: &ProviderEntry,
providers: &HashMap<String, ProviderEntry>,
default_auth: Option<ProviderAuth>,
) -> anyhow::Result<crate::permission::approval::ApprovalFn> {
use crate::permission::approval::{
ApprovalDecision, ApprovalRequest, EVALUATOR_PREAMBLE, build_evaluator_prompt,
parse_decision,
};
let client = std::sync::Arc::new(create_role_client(alias, providers, default_auth)?);
let model_name = entry
.model
.clone()
.unwrap_or_else(|| default_model_for_entry(alias, entry).to_string());
Ok(std::sync::Arc::new(move |req: ApprovalRequest| {
let client = client.clone();
let model_name = model_name.clone();
Box::pin(async move {
let model = client.completion_model(model_name);
let prompt = build_evaluator_prompt(&req);
let raw = summarize::oneshot_with_model(model, "approval", EVALUATOR_PREAMBLE, prompt)
.await?;
Ok::<ApprovalDecision, anyhow::Error>(parse_decision(&raw))
})
as std::pin::Pin<
Box<dyn std::future::Future<Output = anyhow::Result<ApprovalDecision>> + Send>,
>
}))
}
fn build_review_stream_fn(
alias: &str,
entry: &ProviderEntry,
providers: &HashMap<String, ProviderEntry>,
default_auth: Option<ProviderAuth>,
chunk_timeout: std::time::Duration,
loop_tools: &[std::sync::Arc<dyn crate::agent::agent_loop::LoopTool>],
) -> anyhow::Result<(crate::agent::agent_loop::StreamFn, String)> {
use crate::agent::agent_loop::loop_tool_to_rig_definition;
let client = create_role_client(alias, providers, default_auth)?;
let model_name = entry
.model
.clone()
.unwrap_or_else(|| default_model_for_entry(alias, entry).to_string());
let model = client.completion_model(model_name.clone());
let tool_defs: Vec<rig::completion::ToolDefinition> = loop_tools
.iter()
.filter(|t| {
let n = t.name();
n == "memory" || n == "skill"
})
.map(|t| loop_tool_to_rig_definition(t.as_ref()))
.collect();
let stream_fn = model.build_stream_fn(tool_defs, chunk_timeout, Some(alias.to_string()));
Ok((stream_fn, model_name))
}
#[cfg(test)]
mod nw25_tests {
use super::*;
use crate::config::{Config, ProviderAuth};
use clap::Parser;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
static CODEX_AUTH_ENV_LOCK: Mutex<()> = Mutex::new(());
struct TestDir(PathBuf);
impl TestDir {
fn new(tag: &str) -> Self {
let path = std::env::temp_dir().join(format!(
"dirge_provider_build_{tag}_{}_{}",
std::process::id(),
uuid::Uuid::new_v4().simple()
));
std::fs::create_dir_all(&path).unwrap();
Self(path)
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TestDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
struct EnvGuard {
key: &'static str,
old: Option<String>,
}
impl EnvGuard {
fn set_path(key: &'static str, value: &Path) -> Self {
let old = std::env::var(key).ok();
unsafe { std::env::set_var(key, value) };
Self { key, old }
}
fn remove(key: &'static str) -> Self {
let old = std::env::var(key).ok();
unsafe { std::env::remove_var(key) };
Self { key, old }
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
unsafe {
match &self.old {
Some(value) => std::env::set_var(self.key, value),
None => std::env::remove_var(self.key),
}
}
}
}
#[test]
fn resolve_subagent_model_none_when_unset() {
let cfg = Config::default();
assert!(cfg.subagent_provider.is_none());
assert!(
resolve_subagent_model(&cfg).is_none(),
"unset subagent_provider must yield no override model"
);
}
#[test]
fn api_billing_fallback_prefers_resolved_api_key_file_or_stdin_key() {
let mut cli = Cli::parse_from(["dirge", "--api-key", "argv-key"]);
cli.resolved_api_key = Some("resolved-key".to_string());
assert_eq!(openai_api_billing_fallback_key(&cli), Some("resolved-key"));
}
#[test]
fn role_clients_use_top_level_chatgpt_auth() {
let _lock = CODEX_AUTH_ENV_LOCK.lock().unwrap();
let dir = TestDir::new("codex_auth");
std::fs::write(
dir.path().join("auth.json"),
r#"{"access_token":"FAKE-CODEX-TOKEN","chatgpt_account_id":"acct-test"}"#,
)
.unwrap();
let _home = EnvGuard::set_path("CODEX_HOME", dir.path());
let _access = EnvGuard::remove("CODEX_ACCESS_TOKEN");
let _account = EnvGuard::remove("CHATGPT_ACCOUNT_ID");
let client =
create_role_client("openai", &HashMap::new(), Some(ProviderAuth::ChatGpt)).unwrap();
assert!(matches!(client, AnyClient::ChatGptOpenAI(_)));
}
}