use super::*;
pub(super) fn governor_throttle_signal_for_error(
err: &VmError,
) -> Option<crate::llm::rate_governor::ThrottleSignal> {
use crate::llm::rate_governor::ThrottleSignal;
let category = crate::value::error_to_category(err);
if category == crate::value::ErrorCategory::Overloaded {
return Some(ThrottleSignal::Overloaded);
}
let rate_limited = crate::llm::api::classify_llm_error(category, &err.to_string()).reason
== crate::llm::api::LlmErrorReason::RateLimit;
if rate_limited {
return Some(ThrottleSignal::RateLimit429);
}
None
}
pub(super) fn governor_estimated_tokens(opts: &super::api::LlmCallOptions) -> u64 {
let projection = super::cost::project_llm_call_cost(opts, 0.0);
(projection.projected_input_tokens.max(0) + projection.projected_output_tokens.max(0)) as u64
}
pub(super) async fn await_governor_admission(
provider: &str,
org_key: &str,
est_tokens: u64,
) -> bool {
use crate::llm::rate_governor::{gate, GateOutcome};
if !crate::llm::rate_governor::enabled() {
return false;
}
const GOVERNOR_MAX_ADMISSION_WAITS: usize = 256;
for _ in 0..GOVERNOR_MAX_ADMISSION_WAITS {
match gate(provider, org_key, est_tokens) {
GateOutcome::Proceed => return true,
GateOutcome::Wait(d) | GateOutcome::CircuitOpen(d) => {
crate::clock_mock::sleep(d).await;
}
}
}
matches!(gate(provider, org_key, est_tokens), GateOutcome::Proceed)
}
pub(super) fn record_governor_call_outcome(
provider: &str,
org_key: &str,
reserved: bool,
llm_result: &Result<super::api::LlmResult, VmError>,
) {
use crate::llm::rate_governor::{self, GovernorOutcome, ThrottleSignal};
if !reserved {
return;
}
let (outcome, throttle) = match llm_result {
Ok(result) => {
let committed_nothing = result.committed_nothing_usable();
if let Some(signal) = ThrottleSignal::classify(
None,
"",
committed_nothing,
rate_governor::provider_already_throttled(provider, org_key),
) {
(
GovernorOutcome::Throttled {
signal,
retry_after_ms: None,
},
Some((signal, None)),
)
} else {
(GovernorOutcome::Served, None)
}
}
Err(err) => match governor_throttle_signal_for_error(err) {
Some(signal) => {
let retry_after_ms = extract_retry_after_ms(err);
(
GovernorOutcome::Throttled {
signal,
retry_after_ms,
},
Some((signal, retry_after_ms)),
)
}
None => (GovernorOutcome::Neutral, None),
},
};
rate_governor::record_outcome(provider, org_key, outcome);
if let Some((signal, retry_after_ms)) = throttle {
emit_provider_throttle(provider, org_key, signal, retry_after_ms);
}
}
pub(super) fn emit_provider_throttle(
provider: &str,
org_key: &str,
signal: crate::llm::rate_governor::ThrottleSignal,
retry_after_ms: Option<u64>,
) {
let ts = chrono_now();
append_llm_transcript_entry(&crate::llm::rate_governor::build_throttle_record(
provider,
org_key,
signal,
None,
retry_after_ms,
ts.clone(),
));
if let Some(snapshot) = crate::llm::rate_governor::snapshot(provider, org_key) {
append_llm_transcript_entry(&crate::llm::rate_governor::build_state_record(
provider, org_key, &snapshot, ts,
));
}
}
pub(super) fn shared_cooldown_ms_for_llm_error(err: &VmError) -> u64 {
let category = crate::value::error_to_category(err);
let overloaded = category == crate::value::ErrorCategory::Overloaded;
let rate_limited = crate::llm::api::classify_llm_error(category, &err.to_string()).reason
== crate::llm::api::LlmErrorReason::RateLimit;
if !overloaded && !rate_limited {
return 0;
}
extract_retry_after_ms(err).unwrap_or(if overloaded {
super::rate_limit::OVERLOAD_COOLDOWN_MS
} else {
0
})
}
#[cfg(test)]
pub(super) fn is_empty_completion_retry_error(err: &VmError) -> bool {
empty_completion_retry_reason(err).is_some()
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum UnproductiveCompletionReason {
EmptyGeneration,
UnproductiveCompletion,
}
impl UnproductiveCompletionReason {
pub(super) fn as_str(self) -> &'static str {
match self {
Self::EmptyGeneration => "empty_generation",
Self::UnproductiveCompletion => "unproductive_completion",
}
}
}
pub(super) fn empty_completion_retry_reason(err: &VmError) -> Option<UnproductiveCompletionReason> {
if let VmError::Thrown(crate::value::VmValue::Dict(fields)) = err {
return match fields
.get("code")
.map(crate::value::VmValue::display)
.as_deref()
{
Some("empty_generation") => Some(UnproductiveCompletionReason::EmptyGeneration),
Some("unproductive_completion") => {
Some(UnproductiveCompletionReason::UnproductiveCompletion)
}
_ => None,
};
}
let msg = match err {
VmError::Thrown(crate::value::VmValue::String(s)) => s.as_ref(),
VmError::CategorizedError { message, .. } => message.as_str(),
VmError::Runtime(s) => s.as_str(),
_ => return None,
};
let lower = msg.to_lowercase();
if !lower.contains("completion_tokens=") {
return None;
}
if lower.contains("delivered no content") {
return Some(UnproductiveCompletionReason::EmptyGeneration);
}
if lower.contains("no dispatchable tool call or answer")
&& lower.contains("upstream contract violation")
{
return Some(UnproductiveCompletionReason::UnproductiveCompletion);
}
None
}
pub(super) fn is_billed_noncommittal_throw(err: &VmError) -> bool {
let msg = match err {
VmError::Thrown(crate::value::VmValue::String(s)) => s.as_ref(),
VmError::CategorizedError { message, .. } => message.as_str(),
VmError::Runtime(s) => s.as_str(),
VmError::Thrown(crate::value::VmValue::Dict(d)) => {
return d
.get("message")
.map(|v| v.display())
.map(|m| message_is_billed_noncommittal_throw(&m))
.unwrap_or(false);
}
_ => return false,
};
message_is_billed_noncommittal_throw(msg)
}
pub(super) fn message_is_billed_noncommittal_throw(msg: &str) -> bool {
let lower = msg.to_lowercase();
let billed_noncommittal = lower.contains("completion_tokens=")
&& lower.contains("no dispatchable tool call or answer")
&& lower.contains("upstream contract violation");
let function_call_refusal = lower.contains("function call")
&& (lower.contains("did not complete") || lower.contains("not complete it"));
billed_noncommittal || function_call_refusal
}
pub(super) fn is_native_tool_channel_failure(err: &VmError) -> bool {
let msg = match err {
VmError::Thrown(crate::value::VmValue::String(s)) => s.as_ref(),
VmError::CategorizedError { message, .. } => message.as_str(),
VmError::Runtime(s) => s.as_str(),
VmError::Thrown(crate::value::VmValue::Dict(d)) => {
return d
.get("message")
.map(|v| v.display())
.map(|m| message_is_native_tool_channel_failure(&m))
.unwrap_or(false);
}
_ => return false,
};
message_is_native_tool_channel_failure(msg)
}
pub(super) fn message_is_native_tool_channel_failure(msg: &str) -> bool {
let lower = msg.to_lowercase();
let server_error = lower.contains("[http_error]")
|| lower.contains("server_error")
|| lower.contains(" 500")
|| lower.contains("status 500")
|| lower.contains("status: 500")
|| lower.contains("502")
|| lower.contains("api_error");
let stream_cut = lower.contains("unexpected eof")
|| lower.contains("eof while parsing")
|| lower.contains("error decoding stream");
if !server_error && !stream_cut {
return false;
}
lower.contains("tool")
&& (lower.contains("parse")
|| lower.contains("parser")
|| lower.contains("extract")
|| lower.contains("eof"))
}
pub(super) fn is_stream_transport_failure(err: &VmError) -> bool {
let msg = match err {
VmError::Thrown(crate::value::VmValue::String(s)) => s.as_ref(),
VmError::CategorizedError { message, .. } => message.as_str(),
VmError::Runtime(s) => s.as_str(),
VmError::Thrown(crate::value::VmValue::Dict(d)) => {
return d
.get("message")
.map(|v| v.display())
.map(|m| message_is_stream_transport_failure(&m))
.unwrap_or(false);
}
_ => return false,
};
message_is_stream_transport_failure(msg)
}
pub(super) fn message_is_stream_transport_failure(msg: &str) -> bool {
let lower = msg.to_lowercase();
lower.contains("stream error")
&& (lower.contains("mid-stream")
|| lower.contains("response body")
|| lower.contains("body")
|| lower.contains("error decoding stream")
|| lower.contains("connection reset"))
}
pub(super) fn can_degrade_stream_transport(opts: &super::api::LlmCallOptions) -> bool {
opts.stream
&& !crate::llm::capabilities::lookup(&opts.provider, &opts.model).requires_streaming
&& !crate::llm::provider::provider_uses_ollama_messages(&opts.provider, &opts.model)
}
pub(super) fn is_empty_unproductive_completion(result: &super::api::LlmResult) -> bool {
let truncated = matches!(
result
.stop_reason
.as_deref()
.map(str::to_ascii_lowercase)
.as_deref(),
Some("length" | "max_tokens")
);
let has_tool_search_block = result.blocks.iter().any(|block| {
matches!(
block.get("type").and_then(|value| value.as_str()),
Some("tool_search_query") | Some("tool_search_result")
)
});
result.committed_nothing_usable() && !truncated && !has_tool_search_block
}
pub(super) fn is_errored_actionless_completion(result: &super::api::LlmResult) -> bool {
let stop = result
.stop_reason
.as_deref()
.map(str::to_ascii_lowercase)
.unwrap_or_default();
if stop != "error" {
return false;
}
let has_tool_search_block = result.blocks.iter().any(|block| {
matches!(
block.get("type").and_then(|value| value.as_str()),
Some("tool_search_query") | Some("tool_search_result")
)
});
result.tool_calls.is_empty() && !has_tool_search_block
}
pub(super) fn is_retryable_unproductive_completion(result: &super::api::LlmResult) -> bool {
is_empty_unproductive_completion(result) || is_errored_actionless_completion(result)
}
pub(super) fn terminal_unproductive_completion_failover_error(
opts: &super::api::LlmCallOptions,
result: &super::api::LlmResult,
provider_under_throttle: bool,
attempt_count: usize,
duration_ms: Option<u64>,
) -> Option<VmError> {
if crate::llm::providers::is_internal_simulator(&opts.provider)
|| !is_retryable_unproductive_completion(result)
{
return None;
}
if is_empty_unproductive_completion(result) {
let detail = format!(
"returned completion_tokens={} and delivered no content, thinking, or tool calls",
result.output_tokens
);
return Some(provider_exhausted_error(
opts,
UnproductiveCompletionReason::EmptyGeneration,
attempt_count,
duration_ms,
format!(
"provider {} model {} exhausted empty-completion retry budget: reason=empty_generation attempt_count={attempt_count}; {detail}",
opts.provider, opts.model
),
));
}
if !provider_under_throttle {
return None;
}
let detail = format!(
"ended with stop_reason={} after completion_tokens={} and delivered no dispatchable tool call",
result.stop_reason.as_deref().unwrap_or("unknown"),
result.output_tokens
);
Some(provider_exhausted_error(
opts,
UnproductiveCompletionReason::UnproductiveCompletion,
attempt_count,
duration_ms,
format!(
"provider {} model {} exhausted unproductive-completion retry budget while rate governor circuit_open/under throttle: reason=unproductive_completion attempt_count={attempt_count}; {detail}",
opts.provider, opts.model,
),
))
}
pub(super) fn terminal_unproductive_completion_failure(
opts: &super::api::LlmCallOptions,
result: &super::api::LlmResult,
provider_under_throttle: bool,
attempt_count: usize,
duration_ms: u64,
) -> Option<VmError> {
let error = terminal_unproductive_completion_failover_error(
opts,
result,
provider_under_throttle,
attempt_count,
Some(duration_ms),
)?;
let reason = if is_empty_unproductive_completion(result) {
UnproductiveCompletionReason::EmptyGeneration
} else {
UnproductiveCompletionReason::UnproductiveCompletion
};
super::rate_limit::observe_unproductive_completion_for_llm_call(opts, reason.as_str());
Some(error)
}
pub(super) fn provider_exhausted_error(
opts: &super::api::LlmCallOptions,
reason: UnproductiveCompletionReason,
attempt_count: usize,
duration_ms: Option<u64>,
message: String,
) -> VmError {
let mut attempt = std::collections::BTreeMap::from([
(
"provider".to_string(),
VmValue::String(arcstr::ArcStr::from(opts.provider.clone())),
),
(
"model".to_string(),
VmValue::String(arcstr::ArcStr::from(opts.model.clone())),
),
(
"attempt_count".to_string(),
VmValue::Int(attempt_count as i64),
),
(
"reason".to_string(),
VmValue::String(arcstr::ArcStr::from(reason.as_str())),
),
]);
if let Some(duration_ms) = duration_ms {
attempt.insert("duration_ms".to_string(), VmValue::Int(duration_ms as i64));
}
super::routing::provider_exhausted_error(
"circuit_open",
reason.as_str(),
attempt_count,
message,
VmValue::List(std::sync::Arc::new(vec![VmValue::dict(attempt)])),
)
}
pub(super) fn emit_empty_completion_retry(
iteration: usize,
attempt: usize,
opts: &super::api::LlmCallOptions,
reason: UnproductiveCompletionReason,
duration_ms: u64,
error: &str,
) {
append_llm_observability_entry(
"empty_completion_retry",
serde_json::Map::from_iter([
(
"schema".to_string(),
serde_json::json!("harn.llm.empty_completion_retry.v1"),
),
(
"receipt_kind".to_string(),
serde_json::json!("empty_completion_retry"),
),
("iteration".to_string(), serde_json::json!(iteration)),
("attempt".to_string(), serde_json::json!(attempt)),
("provider".to_string(), serde_json::json!(opts.provider)),
("model".to_string(), serde_json::json!(opts.model)),
("reason".to_string(), serde_json::json!(reason.as_str())),
("duration_ms".to_string(), serde_json::json!(duration_ms)),
("error".to_string(), serde_json::json!(error)),
]),
);
super::trace::emit_agent_event(super::trace::AgentTraceEvent::EmptyCompletionRetry {
iteration,
attempt,
provider: opts.provider.clone(),
model: opts.model.clone(),
reason: reason.as_str().to_string(),
duration_ms,
error: error.to_string(),
});
}
pub(super) struct ProviderCallErrorObservation<'a> {
pub(super) iteration: usize,
pub(super) call_id: &'a str,
pub(super) attempt: usize,
pub(super) status: &'a str,
pub(super) opts: &'a super::api::LlmCallOptions,
pub(super) category: &'a crate::value::ErrorCategory,
pub(super) classified: &'a super::api::LlmErrorInfo,
pub(super) message: &'a str,
pub(super) retryable: bool,
pub(super) failover_eligible: bool,
pub(super) attempt_count: Option<usize>,
}
pub(super) fn append_provider_call_error_observability(
observation: ProviderCallErrorObservation<'_>,
) {
let ProviderCallErrorObservation {
iteration,
call_id,
attempt,
status,
opts,
category,
classified,
message,
retryable,
failover_eligible,
attempt_count,
} = observation;
let mut fields = serde_json::Map::from_iter([
("iteration".to_string(), serde_json::json!(iteration)),
("call_id".to_string(), serde_json::json!(call_id)),
("attempt".to_string(), serde_json::json!(attempt)),
("status".to_string(), serde_json::json!(status)),
("provider".to_string(), serde_json::json!(opts.provider)),
("model".to_string(), serde_json::json!(opts.model)),
("category".to_string(), serde_json::json!(category.as_str())),
(
"kind".to_string(),
serde_json::json!(classified.kind.as_str()),
),
(
"reason".to_string(),
serde_json::json!(classified.reason.as_str()),
),
("message".to_string(), serde_json::json!(message)),
("retryable".to_string(), serde_json::json!(retryable)),
]);
if failover_eligible {
fields.insert(
"failover_eligible".to_string(),
serde_json::json!(failover_eligible),
);
}
if let Some(attempt_count) = attempt_count {
fields.insert(
"attempt_count".to_string(),
serde_json::json!(attempt_count),
);
}
append_llm_observability_entry("provider_call_error", fields);
}