use crate::providers::model::ModelProvider;
use mermaid_model::models::ModelError;
use mermaid_model::utils::{join_logged, spawn_guarded};
use std::sync::Arc;
use tokio::sync::mpsc;
use super::*;
#[expect(
clippy::too_many_lines,
reason = "one model call end to end: resolve the provider, size the window, pre-stream \
auto-compaction, the stream, then the context-limit retry that re-runs compaction; both \
compaction blocks need the provider, turn, request, token, window and policy, so a helper \
for either takes seven arguments, and the retry must run after the first stream's relay has \
been joined, which keeps the ordering here"
)]
pub(super) async fn dispatch_call_model(
msg_tx: MsgSender,
providers: Option<Arc<ProviderFactory>>,
turn: TurnId,
mut request: mermaid_domain::ChatRequest,
token: tokio_util::sync::CancellationToken,
tasks: crate::providers::TaskBroker,
) {
use mermaid_model::models::UserFacingError;
let Some(factory) = providers else {
let error = UserFacingError {
summary: "not wired".to_string(),
message: "EffectRunner has no ProviderFactory bound".to_string(),
suggestion: "construct via EffectRunner::pair_with_bindings".to_string(),
category: mermaid_model::models::ErrorCategory::Internal,
recoverable: false,
};
let _ = msg_tx.send(Msg::UpstreamError { turn, error }).await;
return;
};
let provider = match factory.resolve(&request.model_id).await {
Ok(p) => p,
Err(e) => {
let error = classify_error_for_ui(&e);
let _ = msg_tx.send(Msg::UpstreamError { turn, error }).await;
return;
},
};
{
let model_id = request.model_id.clone();
let caps = provider.capabilities().clone();
if let Err(e) =
tokio::task::spawn_blocking(move || record_provider_capabilities(&model_id, &caps))
.await
{
tracing::error!(error = %e, "effect: provider-capability telemetry write failed");
}
}
if !request.tools.is_empty() && !provider.capabilities().supports_tools {
let _ = msg_tx
.send(Msg::TransientStatus {
text: format!(
"{} does not advertise tool support; Mermaid will send the turn without tools",
request.model_id
),
})
.await;
request.tools.clear();
}
let sizing = provider.resolve_context_window(&request).await;
let max_context_tokens = sizing.effective.or_else(|| {
mermaid_domain::runtime::infer_static_context_window_for_model_id(&request.model_id)
});
request.resolved_context_window = sizing.effective.or(sizing.model_max);
request.resolved_max_output = sizing.max_output;
let _ = msg_tx
.send(Msg::ProviderContextResolved {
model_id: request.model_id.clone(),
model_max: sizing.model_max,
effective: sizing.effective,
source: sizing.source,
max_output: sizing.max_output,
})
.await;
if request
.messages
.iter()
.any(|m| m.images.as_ref().is_some_and(|v| !v.is_empty()))
{
let supports_vision = provider.supports_vision().await;
let _ = msg_tx
.send(Msg::ProviderVisionResolved {
model_id: request.model_id.clone(),
supports_vision,
warn: true,
})
.await;
}
let context_snapshot =
mermaid_domain::estimate_context_usage_for_request(&request, max_context_tokens);
let _ = msg_tx
.send(Msg::ContextUsageEstimated {
turn,
snapshot: context_snapshot.clone(),
})
.await;
let policy = factory.config().compaction.policy();
let native_compaction = native_compaction_for(
sizing.compacts_natively,
factory.config(),
policy,
max_context_tokens,
);
let mut compacted_before_stream = false;
let pre_stream = match request.requested_compaction.take() {
Some(requested) => Some(CompactionRequest::requested(
request.clone(),
requested,
policy,
)),
None if native_compaction.is_some() => None,
None => mermaid_domain::should_auto_compact(&context_snapshot, &request, policy)
.is_ok()
.then(|| {
CompactionRequest::auto(request.clone(), CompactionTrigger::AutoThreshold, policy)
}),
};
if let Some(compaction) = pre_stream {
let trigger = compaction.trigger;
match mermaid_domain::prepare_compaction(&compaction, max_context_tokens) {
Ok(prepared) => match run_compaction(
Arc::clone(&provider),
turn,
compaction,
prepared,
context_snapshot.clone(),
max_context_tokens,
token.clone(),
)
.await
{
Ok(result) => {
request.messages = result.replacement_messages.clone();
compacted_before_stream = true;
let _ = msg_tx.send(Msg::CompactionFinished { turn, result }).await;
},
Err(err) => {
if token.is_cancelled() {
return;
}
tracing::warn!(
turn = %turn,
error = %err,
trigger = trigger.as_str(),
"pre-stream compaction failed; proceeding with the un-compacted request",
);
let _ = msg_tx
.send(Msg::CompactionFailed {
turn,
trigger,
message: err.to_string(),
kind: mermaid_domain::StatusKind::Warn,
})
.await;
},
},
Err(skip) if trigger == CompactionTrigger::ModelRequested => {
let _ = msg_tx
.send(Msg::CompactionFailed {
turn,
trigger,
message: skip.to_string(),
kind: mermaid_domain::StatusKind::Info,
})
.await;
},
Err(_) => {},
}
}
request.native_compaction = native_compaction;
request.native_tools = native_tools_for(factory.config());
let (stream_tx, stream_rx) = mpsc::channel::<StreamEvent>(256);
let ctx = StreamContext::new(token.clone(), stream_tx, turn);
let relay = spawn_stream_relay(&msg_tx, &token, &tasks, stream_rx, turn);
let mut completed_ok = false;
match provider.chat(request.clone(), ctx).await {
Ok(_final_response) => {
completed_ok = true;
},
Err(mermaid_model::models::ModelError::Cancelled) => {
},
Err(e) => {
let retry_context_limit = !compacted_before_stream && is_context_limit_error(&e);
if retry_context_limit {
let latest_snapshot = mermaid_domain::estimate_context_usage_for_request(
&request,
max_context_tokens,
);
let compaction = CompactionRequest::auto(
request.clone(),
CompactionTrigger::ContextLimitRetry,
policy,
);
if let Ok(prepared) =
mermaid_domain::prepare_compaction(&compaction, max_context_tokens)
{
match run_compaction(
Arc::clone(&provider),
turn,
compaction,
prepared,
latest_snapshot,
max_context_tokens,
token.clone(),
)
.await
{
Ok(result) => {
let mut retry_request = request;
retry_request.messages = result.replacement_messages.clone();
let _ = msg_tx.send(Msg::CompactionFinished { turn, result }).await;
join_logged(relay.take(), "stream_relay").await;
dispatch_provider_stream(
msg_tx,
provider,
turn,
retry_request,
token,
tasks,
)
.await;
return;
},
Err(compact_err) => {
let _ = msg_tx
.send(Msg::CompactionFailed {
turn,
trigger: CompactionTrigger::ContextLimitRetry,
message: compact_err.to_string(),
kind: mermaid_domain::StatusKind::Error,
})
.await;
},
}
}
}
let error = classify_error_for_ui(&e);
run_provider_error_hook(&request.model_id, &error).await;
let _ = msg_tx.send(Msg::UpstreamError { turn, error }).await;
},
}
join_logged(relay.take(), "stream_relay").await;
if completed_ok
&& request.ollama_allow_ram_offload != Some(true)
&& let Some(p) = provider.verify_placement(sizing.effective).await
{
tracing::debug!(
size_vram_bytes = p.size_vram_bytes,
total_bytes = p.total_bytes,
offloaded = p.size_vram_bytes < p.total_bytes,
suggested_num_ctx = ?p.suggested_num_ctx,
"Ollama placement"
);
let _ = msg_tx
.send(Msg::OllamaPlacementResolved {
model_id: request.model_id.clone(),
size_vram_bytes: p.size_vram_bytes,
total_bytes: p.total_bytes,
suggested_num_ctx: p.suggested_num_ctx,
})
.await;
}
}
fn spawn_stream_relay(
msg_tx: &MsgSender,
token: &tokio_util::sync::CancellationToken,
tasks: &crate::providers::TaskBroker,
mut stream_rx: mpsc::Receiver<StreamEvent>,
turn: TurnId,
) -> mermaid_model::utils::AbortOnDrop {
let relay_tx = msg_tx.clone();
let relay_token = token.clone();
let relay_tasks = tasks.clone();
spawn_guarded(async move {
loop {
let event = tokio::select! {
biased;
_ = relay_token.cancelled() => {
while let Ok(buffered) = stream_rx.try_recv() {
if let StreamEvent::Done {
usage,
provider_continuation,
stop_reason,
} = buffered
{
note_stream_usage(&relay_tasks, &usage);
let _ = relay_tx
.send(Msg::StreamDone {
turn,
usage,
provider_continuation,
stop_reason,
})
.await;
}
}
break;
},
ev = stream_rx.recv() => match ev {
Some(ev) => ev,
None => break,
},
};
let msg = match event {
StreamEvent::Text(chunk) => Msg::StreamText { turn, chunk },
StreamEvent::Reasoning(chunk) => Msg::StreamReasoning { turn, chunk },
StreamEvent::ToolCall(call) => Msg::StreamToolCall { turn, call },
StreamEvent::Status(text) => Msg::TransientStatus { text },
StreamEvent::Done {
usage,
provider_continuation,
stop_reason,
} => {
note_stream_usage(&relay_tasks, &usage);
Msg::StreamDone {
turn,
usage,
provider_continuation,
stop_reason,
}
},
};
if relay_tx.send(msg).await.is_err() {
break;
}
}
})
}
pub(super) struct TurnTimer {
turn: TurnId,
model_id: String,
started: std::time::Instant,
}
impl Drop for TurnTimer {
fn drop(&mut self) {
tracing::debug!(
turn = %self.turn,
model = %self.model_id,
elapsed_ms = self.started.elapsed().as_millis() as u64,
"model turn complete"
);
}
}
pub(super) async fn dispatch_provider_stream(
msg_tx: MsgSender,
provider: Arc<dyn ModelProvider>,
turn: TurnId,
request: mermaid_domain::ChatRequest,
token: tokio_util::sync::CancellationToken,
tasks: crate::providers::TaskBroker,
) {
let _turn_timer = TurnTimer {
turn,
model_id: request.model_id.clone(),
started: std::time::Instant::now(),
};
let (stream_tx, stream_rx) = mpsc::channel::<StreamEvent>(256);
let ctx = StreamContext::new(token.clone(), stream_tx, turn);
let relay = spawn_stream_relay(&msg_tx, &token, &tasks, stream_rx, turn);
let model_id = request.model_id.clone();
match provider.chat(request, ctx).await {
Ok(_) | Err(ModelError::Cancelled) => {},
Err(e) => {
let error = classify_error_for_ui(&e);
run_provider_error_hook(&model_id, &error).await;
let _ = msg_tx.send(Msg::UpstreamError { turn, error }).await;
},
}
join_logged(relay.take(), "stream_relay").await;
}
pub(super) async fn fire_plugin_hooks(event: &'static str, payload: serde_json::Value) {
let _ = tokio::task::spawn_blocking(move || mermaid_runtime::run_plugin_hooks(event, &payload))
.await;
}
pub(super) async fn run_plugin_hooks_gated(
event: &'static str,
payload: serde_json::Value,
) -> mermaid_runtime::HookGate {
tokio::task::spawn_blocking(move || {
mermaid_runtime::run_plugin_hooks(event, &payload)
.map(mermaid_runtime::aggregate_hook_responses)
.unwrap_or_default()
})
.await
.unwrap_or_default()
}
pub(super) async fn run_provider_error_hook(
model_id: &str,
error: &mermaid_model::models::UserFacingError,
) {
fire_plugin_hooks(
"provider_error",
serde_json::json!({
"model_id": model_id,
"summary": &error.summary,
"message": &error.message,
"category": format!("{:?}", error.category),
"recoverable": error.recoverable,
}),
)
.await;
}
fn native_tools_for(config: &Config) -> mermaid_model::models::NativeTools {
let on = config.tools.provider_native;
mermaid_model::models::NativeTools {
text_editor: on,
shell: on
&& mermaid_runtime::HostShell::current() != mermaid_runtime::HostShell::PowerShell,
}
}
fn native_compaction_for(
provider_can: bool,
config: &Config,
policy: mermaid_domain::CompactionPolicy,
window: Option<usize>,
) -> Option<mermaid_model::models::NativeCompaction> {
if !(provider_can && policy.auto_enabled && config.compaction.provider_native) {
return None;
}
let window = window?;
Some(mermaid_model::models::NativeCompaction {
trigger_tokens: window * usize::from(policy.auto_threshold_percent) / 100,
})
}