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()))
}
fn resolve_entry_model_name(client: &AnyClient, alias: &str, entry: &ProviderEntry) -> String {
let requested = entry
.model
.clone()
.unwrap_or_else(|| default_model_for_entry(alias, entry).to_string());
super::resolve_model_name(client, &requested, entry.model.is_some())
}
#[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 coordinator_preamble = bg_store_for_loop
.as_ref()
.and_then(crate::agent::tools::background::BackgroundStore::coordinator_preamble);
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, mcp_tool_names) =
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);
}
if let Some(coordinator_preamble) = &coordinator_preamble {
if !preamble.is_empty() {
preamble.push_str("\n\n");
}
preamble.push_str(&coordinator_preamble);
}
let mut agent = AnyAgent::new(
AnyAgentInner::$variant(agent),
cache,
chunk_timeout,
loop_tools,
preamble,
model_name.clone(),
);
agent = agent.with_mcp_tool_names(mcp_tool_names);
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::Cerebras(m) => build_inner!(m, Cerebras),
AnyModel::OpenCode(m) => build_inner!(m, OpenCode),
AnyModel::Kimi(m) => build_inner!(m, Kimi),
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/cerebras/\
opencode/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)) => {
let active_prompt = context
.current_prompt_name
.as_deref()
.and_then(|name| context.prompts.get(name));
let critic_disabled = active_prompt.and_then(|p| p.critic) == Some(false);
let code_review_mode = active_prompt
.and_then(|p| p.code_review.as_deref())
.and_then(crate::agent::agent_loop::types::CodeReviewMode::from_wire)
.unwrap_or_else(|| cfg.resolve_code_review_mode());
let critic_preamble: std::sync::Arc<str> =
match active_prompt.and_then(|p| p.critic_preamble.as_deref()) {
Some(p) => std::sync::Arc::from(p),
None => std::sync::Arc::from(cfg.resolve_critic_preamble()),
};
let providers = cfg.providers_map();
match create_role_client(&alias, &providers, cfg.auth) {
Ok(raw_client) => {
let client = std::sync::Arc::new(raw_client);
let model_name = resolve_entry_model_name(&client, &alias, &entry);
agent = agent.with_goal_fn(build_judge_fn(
client.clone(),
model_name.clone(),
"critic",
std::sync::Arc::from(crate::agent::agent_loop::goal::GOAL_PREAMBLE),
));
if !critic_disabled {
use crate::agent::agent_loop::types::CodeReviewMode;
let judge_preamble: std::sync::Arc<str> =
if code_review_mode != CodeReviewMode::Off {
std::sync::Arc::from(format!(
"{critic_preamble}\n\n{}",
crate::agent::agent_loop::code_review::REVIEW_PREAMBLE,
))
} else {
critic_preamble.clone()
};
agent = agent.with_critic(build_judge_fn(
client.clone(),
model_name.clone(),
"critic",
judge_preamble,
));
agent = agent.with_classify_fn(build_classify_fn(
client.clone(),
model_name.clone(),
));
if code_review_mode != CodeReviewMode::Off {
agent = agent.with_code_review_fn(build_judge_fn(
client.clone(),
model_name.clone(),
"code-review",
std::sync::Arc::from(
crate::agent::agent_loop::code_review::REVIEW_PREAMBLE,
),
));
agent = agent.with_code_review_mode(code_review_mode);
}
tracing::info!(
target: "dirge::provider",
alias = %alias,
code_review = code_review_mode.as_str(),
"unified finalization judge wired (critic + diff review per mode)",
);
} else {
tracing::info!(
target: "dirge::provider",
alias = %alias,
"critic disabled by active prompt; goal gate unaffected",
);
}
}
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(threshold) = cfg.progress_stall_threshold {
agent = agent.with_progress_stall_threshold(threshold);
}
if let Some(cap) = cfg.progress_prologue_cap {
agent = agent.with_progress_prologue_cap(cap);
}
agent = agent.with_open_issues_gate_mode(cfg.resolve_open_issues_gate_mode());
agent = agent.with_verification_tiers_mode(cfg.resolve_verification_tiers_mode());
agent = agent.with_verification_command(cfg.verification_command.clone());
agent = agent.with_safe_state_abort_mode(cfg.resolve_safe_state_abort_mode());
agent = agent.with_session_id(session_id);
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());
crate::provider::set_current_agent(std::sync::Arc::new(agent.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, retrying_stream_fn};
use crate::agent::recovery::RecoveryPolicy;
let client = create_role_client(alias, providers, default_auth)?;
let model_name = resolve_entry_model_name(&client, alias, entry);
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(retrying_stream_fn(
model.build_stream_fn(tool_defs, chunk_timeout, Some(alias.to_string())),
RecoveryPolicy::default(),
))
}
fn build_classify_fn(
client: std::sync::Arc<AnyClient>,
model_name: String,
) -> crate::agent::agent_loop::critic::ClassifyFn {
std::sync::Arc::new(move |question: String, options: &'static [&'static str]| {
let client = client.clone();
let model_name = model_name.clone();
Box::pin(async move {
use crate::agent::agent_loop::critic::{
CLASSIFY_PREAMBLE, classify_prompt, classify_retry_prompt, parse_choice,
};
let preamble: std::sync::Arc<str> = std::sync::Arc::from(CLASSIFY_PREAMBLE);
let model = client.completion_model(model_name);
let prompt = classify_prompt(&question, options);
let raw =
summarize::oneshot_with_model(model.clone(), "classify", &preamble, prompt).await?;
if let Some(idx) = parse_choice(&raw, options) {
return Ok(idx);
}
let retry = classify_retry_prompt(options);
let raw2 =
summarize::oneshot_with_model(model, "classify-retry", &preamble, retry).await?;
parse_choice(&raw2, options)
.ok_or_else(|| anyhow::anyhow!("classifier did not choose one of {options:?}"))
})
as std::pin::Pin<Box<dyn std::future::Future<Output = anyhow::Result<usize>> + Send>>
})
}
fn build_judge_fn(
client: std::sync::Arc<AnyClient>,
model_name: String,
label: &'static str,
preamble: std::sync::Arc<str>,
) -> crate::agent::agent_loop::critic::CriticFn {
std::sync::Arc::new(move |prompt: String| {
let client = client.clone();
let model_name = model_name.clone();
let preamble = preamble.clone();
Box::pin(async move {
let model = client.completion_model(model_name);
summarize::oneshot_with_model(model, label, &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> {
resolve_summarization_model(
cfg,
main_client.completion_model(main_model_name.to_string()),
)
}
pub(crate) fn resolve_summarization_model(
cfg: &Config,
fallback: AnyModel,
) -> 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 = resolve_entry_model_name(&client, &alias, &entry);
tracing::info!(
target: "dirge::provider",
alias = %alias,
"summarization_provider active for 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!(&fallback, AnyModel::AnthropicOauth(_)) {
anyhow::bail!(ANTHROPIC_OAUTH_COMPACTION_DISABLED);
}
Ok(fallback)
}
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 })
})
};
match resolve_summarization_model(cfg, main_model) {
Ok(model) => from_model(model),
Err(_) => anthropic_oauth_compaction_disabled_fn(),
}
}
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 = resolve_entry_model_name(&client, &alias, &entry);
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 resolve_profile_model(
cfg: &Config,
active_client: &AnyClient,
active_provider: &str,
profile: &str,
model: Option<&str>,
clients: &mut HashMap<String, AnyClient>,
) -> Option<AnyModel> {
use super::{ModelRoute, RouteRefusal, build_route_client, resolve_model_route};
let model = crate::context::agent_defs::resolve_model_alias(cfg, model)?;
let fall_back = |refusal: RouteRefusal, model: String| {
eprintln!(
"warning: agent '{profile}': {refusal} Running it on '{active_provider}' instead, \
where it may be rejected."
);
Some(active_client.completion_model(model))
};
match resolve_model_route(cfg, active_provider, &model) {
ModelRoute::Active(model) => Some(active_client.completion_model(model)),
ModelRoute::Provider { alias, model } => {
if !clients.contains_key(&alias) {
match build_route_client(cfg, &alias, &model) {
Ok(client) => {
clients.insert(alias.clone(), client);
}
Err(refusal) => return fall_back(refusal, model),
}
}
tracing::info!(
target: "dirge::agents",
agent = %profile,
alias = %alias,
model = %model,
"agent profile model routed to its family's provider",
);
Some(clients[&alias].completion_model(model))
}
ModelRoute::Unroutable { model, family } => fall_back(
RouteRefusal::NoProviderForFamily {
model: model.clone(),
family,
},
model,
),
}
}
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 = resolve_entry_model_name(&client, alias, entry);
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 = resolve_entry_model_name(&client, alias, entry);
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 profile_model_builds_against_its_family_provider() {
let cfg = issue_711_config();
let providers = cfg.providers_map();
let active = create_role_client("gpt-sol", &providers, None).unwrap();
let mut clients = HashMap::new();
let model = resolve_profile_model(
&cfg,
&active,
"gpt-sol",
"researcher",
Some("glm-5.2"),
&mut clients,
)
.expect("a pinned model must yield a route model");
assert_eq!(model.provider_name(), "glm");
assert_eq!(model.name(), "glm-5.2");
}
#[test]
fn profile_model_naming_an_alias_resolves_then_cross_routes() {
let cfg = issue_711_config();
let providers = cfg.providers_map();
let active = create_role_client("gpt-sol", &providers, None).unwrap();
let mut clients = HashMap::new();
let model = resolve_profile_model(
&cfg,
&active,
"gpt-sol",
"researcher",
Some("glm"),
&mut clients,
)
.unwrap();
assert_eq!(model.provider_name(), "glm");
assert_eq!(model.name(), "glm-5.2");
}
#[test]
fn profile_model_in_the_active_family_uses_the_active_client() {
let cfg = issue_711_config();
let providers = cfg.providers_map();
let active = create_role_client("gpt-sol", &providers, None).unwrap();
let mut clients = HashMap::new();
let model = resolve_profile_model(
&cfg,
&active,
"gpt-sol",
"researcher",
Some("gpt-5.5-mini"),
&mut clients,
)
.unwrap();
assert_eq!(model.provider_name(), "openai");
assert_eq!(model.name(), "gpt-5.5-mini");
assert!(clients.is_empty(), "no cross-provider client was needed");
}
#[test]
fn cross_routed_clients_are_built_once_per_alias() {
let cfg = issue_711_config();
let providers = cfg.providers_map();
let active = create_role_client("gpt-sol", &providers, None).unwrap();
let mut clients = HashMap::new();
for profile in ["researcher", "implementer", "reviewer"] {
let model = resolve_profile_model(
&cfg,
&active,
"gpt-sol",
profile,
Some("glm-5.2"),
&mut clients,
)
.unwrap();
assert_eq!(model.provider_name(), "glm");
}
assert_eq!(clients.len(), 1, "one client per target alias");
}
#[test]
fn profile_without_a_model_yields_no_route_model() {
let cfg = issue_711_config();
let providers = cfg.providers_map();
let active = create_role_client("gpt-sol", &providers, None).unwrap();
let mut clients = HashMap::new();
assert!(
resolve_profile_model(&cfg, &active, "gpt-sol", "researcher", None, &mut clients)
.is_none()
);
}
#[test]
fn unroutable_family_falls_back_to_the_active_client() {
let cfg = issue_711_config();
let providers = cfg.providers_map();
let active = create_role_client("gpt-sol", &providers, None).unwrap();
let mut clients = HashMap::new();
let model = resolve_profile_model(
&cfg,
&active,
"gpt-sol",
"reviewer",
Some("claude-opus-4"),
&mut clients,
)
.unwrap();
assert_eq!(model.provider_name(), "openai");
assert_eq!(model.name(), "claude-opus-4");
}
fn issue_711_config() -> Config {
use crate::config::ProviderEntry;
let mut providers = HashMap::new();
providers.insert(
"gpt-sol".to_string(),
ProviderEntry {
provider_type: Some("openai".to_string()),
model: Some("gpt-5.5".to_string()),
api_key: Some("sk-test-openai".to_string()),
..Default::default()
},
);
providers.insert(
"glm".to_string(),
ProviderEntry {
provider_type: Some("glm".to_string()),
model: Some("glm-5.2".to_string()),
api_key: Some("sk-test-glm".to_string()),
..Default::default()
},
);
Config {
providers: Some(providers),
..Default::default()
}
}
#[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(_)));
}
}