use super::helpers::{calculate_backoff_delay, send_event};
use super::types::{
CONNECTIVITY_PROBE_MIN_DELAY, LLM_CALL_TOTAL_TIMEOUT, LLM_STREAM_INACTIVITY_TIMEOUT,
LlmEventContext, LlmOutcome, LlmStreamIds, MAX_REACHABLE_CONNECTIVITY_FAILURES, StreamError,
};
use crate::events::AgentEvent;
use crate::hooks::{AgentHooks, RequestDecision, ResponseDecision};
use crate::llm::{
ChatOutcome, ChatRequest, ChatResponse, LlmProvider, StreamAccumulator, StreamDelta, Usage,
};
use crate::types::{AgentConfig, AgentError};
use futures::StreamExt;
use log::{error, warn};
use std::sync::Arc;
use tokio::time::sleep;
pub(super) enum PreLlmGuardrail {
Proceed(Box<ChatRequest>),
Blocked(String),
}
pub(super) async fn apply_pre_llm_request<H>(
hooks: &Arc<H>,
request: ChatRequest,
) -> PreLlmGuardrail
where
H: AgentHooks,
{
match hooks.pre_llm_request(&request).await {
RequestDecision::Modify(modified) => PreLlmGuardrail::Proceed(modified),
RequestDecision::Block(reason) => PreLlmGuardrail::Blocked(reason),
_ => PreLlmGuardrail::Proceed(Box::new(request)),
}
}
pub(super) enum PostLlmGuardrail {
Accept,
Blocked(String),
RetryWithFeedback(String),
}
pub(super) async fn apply_on_llm_response<H>(
hooks: &Arc<H>,
response: &ChatResponse,
) -> PostLlmGuardrail
where
H: AgentHooks,
{
match hooks.on_llm_response(response).await {
ResponseDecision::Block(reason) => PostLlmGuardrail::Blocked(reason),
ResponseDecision::RetryWithFeedback(feedback) => {
PostLlmGuardrail::RetryWithFeedback(feedback)
}
_ => PostLlmGuardrail::Accept,
}
}
#[cfg(feature = "otel")]
pub(super) struct LlmSpanObserver<'a> {
pub(super) span: &'a mut opentelemetry::global::BoxedSpan,
pub(super) provider_name: &'static str,
pub(super) request_model: &'a str,
}
#[cfg(feature = "otel")]
impl LlmSpanObserver<'_> {
fn record_first_chunk(&mut self, turn: usize, streaming: bool, ttfc_secs: f64) {
use crate::observability::{attrs, metrics, spans};
use opentelemetry::KeyValue;
use opentelemetry::trace::Span;
spans::add_event(
self.span,
"llm.stream.first_chunk",
vec![
attrs::kv_i64(attrs::SDK_TURN_NUMBER, i64::try_from(turn).unwrap_or(0)),
attrs::kv_bool(attrs::SDK_LLM_STREAMING, streaming),
],
);
self.span.set_attribute(KeyValue::new(
attrs::GEN_AI_RESPONSE_TIME_TO_FIRST_CHUNK,
ttfc_secs,
));
let metrics_handle = metrics::Metrics::global();
metrics_handle.time_to_first_chunk.record(
ttfc_secs,
&[
KeyValue::new(attrs::GEN_AI_OPERATION_NAME, "chat"),
KeyValue::new(attrs::GEN_AI_PROVIDER_NAME, self.provider_name),
KeyValue::new(attrs::GEN_AI_REQUEST_MODEL, self.request_model.to_string()),
],
);
}
fn record_subsequent_chunk(&self, tpoc_secs: f64) {
use crate::observability::{attrs, metrics};
use opentelemetry::KeyValue;
let metrics_handle = metrics::Metrics::global();
metrics_handle.time_per_output_chunk.record(
tpoc_secs,
&[
KeyValue::new(attrs::GEN_AI_OPERATION_NAME, "chat"),
KeyValue::new(attrs::GEN_AI_PROVIDER_NAME, self.provider_name),
KeyValue::new(attrs::GEN_AI_REQUEST_MODEL, self.request_model.to_string()),
],
);
}
fn record_completed(&mut self, delta_count: u64, duration_ms: u64) {
use crate::observability::{attrs, spans};
spans::add_event(
self.span,
"llm.stream.completed",
vec![
attrs::kv_i64(
attrs::SDK_LLM_STREAM_DELTA_COUNT,
i64::try_from(delta_count).unwrap_or(i64::MAX),
),
attrs::kv_i64(
attrs::SDK_LLM_STREAM_DURATION_MS,
i64::try_from(duration_ms).unwrap_or(i64::MAX),
),
],
);
}
fn record_dropped(&mut self, reason: &'static str, delta_count: u64, error_type: &str) {
use crate::observability::{attrs, spans};
spans::add_event(
self.span,
"llm.stream.dropped",
vec![
opentelemetry::KeyValue::new(attrs::SDK_LLM_STREAM_DROP_REASON, reason),
attrs::kv_i64(
attrs::SDK_LLM_STREAM_DELTA_COUNT,
i64::try_from(delta_count).unwrap_or(i64::MAX),
),
opentelemetry::KeyValue::new(attrs::ERROR_TYPE, error_type.to_string()),
],
);
}
fn record_retry(&mut self, attempt: u32, max_attempts: u32, delay_ms: u64, error_type: &str) {
use crate::observability::{attrs, metrics, spans};
spans::add_event(
self.span,
"llm.retry",
vec![
opentelemetry::KeyValue::new(attrs::GEN_AI_PROVIDER_NAME, self.provider_name),
attrs::kv_i64(attrs::SDK_LLM_RETRY_ATTEMPT, i64::from(attempt)),
attrs::kv_i64(attrs::SDK_LLM_RETRY_MAX_ATTEMPTS, i64::from(max_attempts)),
attrs::kv_i64(
attrs::SDK_LLM_RETRY_DELAY_MS,
i64::try_from(delay_ms).unwrap_or(i64::MAX),
),
opentelemetry::KeyValue::new(attrs::ERROR_TYPE, error_type.to_string()),
],
);
let metrics_handle = metrics::Metrics::global();
metrics_handle.llm_retries.add(
1,
&[
opentelemetry::KeyValue::new(attrs::GEN_AI_PROVIDER_NAME, self.provider_name),
opentelemetry::KeyValue::new(attrs::ERROR_TYPE, error_type.to_string()),
],
);
}
}
enum ProviderCall {
Outcome(ChatOutcome),
Cancelled,
Error(AgentError),
}
async fn chat_or_cancel<P, H>(
provider: &Arc<P>,
request: &ChatRequest,
event_ctx: &LlmEventContext<'_, H>,
) -> ProviderCall
where
P: LlmProvider,
H: AgentHooks,
{
tokio::select! {
biased;
() = event_ctx.cancel_token.cancelled() => {
log::info!("LLM call cancelled (turn={})", event_ctx.turn);
ProviderCall::Cancelled
}
res = tokio::time::timeout(LLM_CALL_TOTAL_TIMEOUT, provider.chat(request.clone())) => match res {
Ok(Ok(outcome)) => ProviderCall::Outcome(outcome),
Ok(Err(e)) => ProviderCall::Error(AgentError::new(format!("LLM error: {e}"), false)),
Err(_elapsed) => {
warn!(
"LLM call exceeded inactivity deadline (turn={}), treating as server error",
event_ctx.turn
);
ProviderCall::Outcome(ChatOutcome::ServerError(
"LLM call timed out (no response within deadline)".to_string(),
))
}
},
}
}
pub(super) async fn call_llm_with_retry<P, H>(
provider: &Arc<P>,
request: ChatRequest,
config: &AgentConfig,
event_ctx: &LlmEventContext<'_, H>,
#[cfg(feature = "otel")] mut span_observer: Option<LlmSpanObserver<'_>>,
) -> (LlmOutcome, u32)
where
P: LlmProvider,
H: AgentHooks,
{
let max_retries = config.retry.max_retries;
let mut attempt = 0u32;
loop {
let outcome = match chat_or_cancel(provider, &request, event_ctx).await {
ProviderCall::Outcome(outcome) => outcome,
ProviderCall::Cancelled => return (LlmOutcome::Cancelled(ZERO_USAGE), attempt),
ProviderCall::Error(error) => return (LlmOutcome::Error(error), attempt),
};
let (kind, retry_reason, failure_message, override_delay) = match outcome {
ChatOutcome::Success(response) => {
if attempt > 0 {
send_auto_retry_end_event(event_ctx, attempt, true, None).await;
}
return (LlmOutcome::Response(response), attempt);
}
ChatOutcome::InvalidRequest(msg) => {
error!("Invalid request to LLM: {msg}");
return (
LlmOutcome::Error(AgentError::new(format!("Invalid request: {msg}"), false)),
attempt,
);
}
ChatOutcome::RateLimited(retry_after) => (
"rate_limited",
"Rate limited by LLM provider".to_string(),
format!("Rate limited after {max_retries} retries"),
retry_after,
),
ChatOutcome::ServerError(msg) => (
"server_error",
msg.clone(),
format!("Server error after {max_retries} retries: {msg}"),
None,
),
_ => (
"server_error",
"Unrecognized provider outcome".to_string(),
format!("Unrecognized provider outcome after {max_retries} retries"),
None,
),
};
attempt += 1;
match handle_retry_backoff(RetryBackoff {
event_ctx,
config,
attempt,
event_attempt: attempt,
settled_event_attempt: attempt.saturating_sub(1),
max_retries,
event_max_retries: max_retries,
error_kind: kind,
retry_reason,
failure_message,
override_delay,
#[cfg(feature = "otel")]
span_observer: span_observer.as_mut(),
#[cfg(not(feature = "otel"))]
_observer: std::marker::PhantomData,
})
.await
{
RetryStep::Retry => {}
RetryStep::Cancelled => return (LlmOutcome::Cancelled(ZERO_USAGE), attempt),
RetryStep::GiveUp(outcome) => return (outcome, attempt),
}
}
}
enum RetryStep {
Retry,
Cancelled,
GiveUp(LlmOutcome),
}
fn fatal_stream_outcome(message: &str) -> LlmOutcome {
error!("Streaming error (non-recoverable): {message}");
LlmOutcome::Error(AgentError::new(
format!("Streaming error: {message}"),
false,
))
}
fn terminal_retry_outcome(
step: RetryStep,
retries: u32,
usage: &Usage,
) -> Option<(LlmOutcome, u32)> {
match step {
RetryStep::Retry => None,
RetryStep::Cancelled => Some((LlmOutcome::Cancelled(usage.clone()), retries)),
RetryStep::GiveUp(outcome) => Some((outcome, retries)),
}
}
const fn mark_retry_emitted(last_attempt: &mut u32, attempt: u32, emitted: bool) {
if emitted {
*last_attempt = attempt;
}
}
fn renew_stream_ids(message_id: &mut String, thinking_id: &mut String) {
*message_id = uuid::Uuid::new_v4().to_string();
*thinking_id = uuid::Uuid::new_v4().to_string();
}
async fn finish_retry_events<H>(event_ctx: &LlmEventContext<'_, H>, last_attempt: u32)
where
H: AgentHooks,
{
if last_attempt > 0 {
send_auto_retry_end_event(event_ctx, last_attempt, true, None).await;
}
}
fn transient_retry_context(
message: &str,
max_retries: u32,
waited_for_connectivity: bool,
) -> (String, u32) {
let failure = format!("Streaming error after {max_retries} retries: {message}");
let event_max = if waited_for_connectivity {
u32::MAX
} else {
max_retries
};
(failure, event_max)
}
async fn give_up_retrying<H>(
event_ctx: &LlmEventContext<'_, H>,
settled_event_attempt: u32,
failure_message: String,
error_kind: &'static str,
) -> LlmOutcome
where
H: AgentHooks,
{
error!("LLM {error_kind} exhausted retries: {failure_message}");
send_auto_retry_end_event(
event_ctx,
settled_event_attempt,
false,
Some(failure_message.clone()),
)
.await;
if let Err(error) = send_llm_error_event(event_ctx, &failure_message).await {
return LlmOutcome::Error(error);
}
LlmOutcome::Error(AgentError::new(failure_message, true))
}
struct RetryBackoff<'a, 'o, H> {
event_ctx: &'a LlmEventContext<'a, H>,
config: &'a AgentConfig,
attempt: u32,
event_attempt: u32,
settled_event_attempt: u32,
max_retries: u32,
event_max_retries: u32,
error_kind: &'static str,
retry_reason: String,
failure_message: String,
override_delay: Option<std::time::Duration>,
#[cfg(feature = "otel")]
span_observer: Option<&'a mut LlmSpanObserver<'o>>,
#[cfg(not(feature = "otel"))]
_observer: std::marker::PhantomData<&'o ()>,
}
async fn handle_retry_backoff<H>(params: RetryBackoff<'_, '_, H>) -> RetryStep
where
H: AgentHooks,
{
let RetryBackoff {
event_ctx,
config,
attempt,
event_attempt,
settled_event_attempt,
max_retries,
event_max_retries,
error_kind,
retry_reason,
failure_message,
override_delay,
#[cfg(feature = "otel")]
span_observer,
#[cfg(not(feature = "otel"))]
_observer: _,
} = params;
if attempt > max_retries {
return RetryStep::GiveUp(
give_up_retrying(
event_ctx,
settled_event_attempt,
failure_message,
error_kind,
)
.await,
);
}
let delay = override_delay.map_or_else(
|| calculate_backoff_delay(attempt, &config.retry),
|hint| clamp_to_max_delay(hint, config.retry.max_delay_ms),
);
let delay_ms = u64::try_from(delay.as_millis()).unwrap_or(u64::MAX);
warn!(
"LLM {error_kind}, retrying (retry={event_attempt}, class_attempt={attempt}, delay_ms={delay_ms})"
);
#[cfg(feature = "otel")]
if let Some(observer) = span_observer {
observer.record_retry(event_attempt, event_max_retries, delay_ms, error_kind);
}
send_auto_retry_start_event(
event_ctx,
event_attempt,
event_max_retries,
delay_ms,
&retry_reason,
)
.await;
if sleep_or_cancel(delay, event_ctx.cancel_token)
.await
.is_break()
{
return RetryStep::Cancelled;
}
RetryStep::Retry
}
fn clamp_to_max_delay(delay: std::time::Duration, max_delay_ms: u64) -> std::time::Duration {
let ms = u64::try_from(delay.as_millis())
.unwrap_or(u64::MAX)
.min(max_delay_ms);
std::time::Duration::from_millis(ms)
}
async fn sleep_or_cancel(
delay: std::time::Duration,
cancel_token: &tokio_util::sync::CancellationToken,
) -> std::ops::ControlFlow<()> {
tokio::select! {
biased;
() = cancel_token.cancelled() => std::ops::ControlFlow::Break(()),
() = sleep(delay) => std::ops::ControlFlow::Continue(()),
}
}
struct StreamingRetryState {
total: u32,
transient: u32,
reachable_connectivity_deaths: u32,
in_connectivity_streak: bool,
had_connectivity: bool,
last_emitted: u32,
abandoned_usage: Usage,
}
impl StreamingRetryState {
const fn new() -> Self {
Self {
total: 0,
transient: 0,
reachable_connectivity_deaths: 0,
in_connectivity_streak: false,
had_connectivity: false,
last_emitted: 0,
abandoned_usage: ZERO_USAGE,
}
}
const fn record_connectivity_failure(&mut self, usage: &Usage) -> bool {
add_usage(&mut self.abandoned_usage, usage);
self.total = self.total.saturating_add(1);
self.reachable_connectivity_deaths = self.reachable_connectivity_deaths.saturating_add(1);
self.had_connectivity = true;
let starts_streak = !self.in_connectivity_streak;
self.in_connectivity_streak = true;
starts_streak
}
const fn record_transient_failure(&mut self, usage: &Usage, max_retries: u32) -> bool {
add_usage(&mut self.abandoned_usage, usage);
self.transient = self.transient.saturating_add(1);
self.total = self.total.saturating_add(1);
self.in_connectivity_streak = false;
self.transient <= max_retries
}
}
fn connectivity_probe_delay(probe_round: u32, config: &AgentConfig) -> std::time::Duration {
calculate_backoff_delay(probe_round, &config.retry).max(CONNECTIVITY_PROBE_MIN_DELAY)
}
async fn wait_for_connectivity<P, H>(
provider: &Arc<P>,
config: &AgentConfig,
event_ctx: &LlmEventContext<'_, H>,
retry: &mut StreamingRetryState,
) -> std::ops::ControlFlow<()>
where
P: LlmProvider,
H: AgentHooks,
{
let mut probe_round: u32 = 0;
loop {
probe_round = probe_round.saturating_add(1);
let delay = connectivity_probe_delay(probe_round, config);
if sleep_or_cancel(delay, event_ctx.cancel_token)
.await
.is_break()
{
return std::ops::ControlFlow::Break(());
}
let reachable = tokio::select! {
biased;
() = event_ctx.cancel_token.cancelled() => return std::ops::ControlFlow::Break(()),
reachable = provider.probe_connectivity() => reachable,
};
if reachable {
return std::ops::ControlFlow::Continue(());
}
retry.reachable_connectivity_deaths = 0;
}
}
async fn handle_connectivity_loss<P, H>(
provider: &Arc<P>,
config: &AgentConfig,
event_ctx: &LlmEventContext<'_, H>,
retry: &mut StreamingRetryState,
message: &str,
usage: &Usage,
#[cfg(feature = "otel")] span_observer: Option<&mut LlmSpanObserver<'_>>,
) -> Option<(LlmOutcome, u32)>
where
P: LlmProvider,
H: AgentHooks,
{
let starts_streak = retry.record_connectivity_failure(usage);
if retry.reachable_connectivity_deaths > MAX_REACHABLE_CONNECTIVITY_FAILURES {
let failure = format!(
"Streaming connection died {} consecutive times while the provider \
stayed reachable: {message}",
retry.reachable_connectivity_deaths
);
let outcome =
give_up_retrying(event_ctx, retry.last_emitted, failure, "connectivity").await;
return Some((outcome, retry.total));
}
let delay_ms =
u64::try_from(connectivity_probe_delay(1, config).as_millis()).unwrap_or(u64::MAX);
warn!(
"LLM connectivity loss, waiting for reachability (failure={}, reachable_deaths={}): {message}",
retry.total, retry.reachable_connectivity_deaths
);
#[cfg(feature = "otel")]
if let Some(observer) = span_observer {
observer.record_retry(retry.total, u32::MAX, delay_ms, "connectivity");
}
if starts_streak {
retry.last_emitted = retry.total;
send_auto_retry_start_event(event_ctx, retry.total, u32::MAX, delay_ms, message).await;
}
if wait_for_connectivity(provider, config, event_ctx, retry)
.await
.is_break()
{
return Some((
LlmOutcome::Cancelled(retry.abandoned_usage.clone()),
retry.total,
));
}
None
}
pub(super) async fn call_llm_streaming<P, H>(
provider: &Arc<P>,
request: ChatRequest,
config: &AgentConfig,
event_ctx: &LlmEventContext<'_, H>,
message_id: &mut String,
thinking_id: &mut String,
#[cfg(feature = "otel")] mut span_observer: Option<LlmSpanObserver<'_>>,
) -> (LlmOutcome, u32)
where
P: LlmProvider,
H: AgentHooks,
{
let max_retries = config.retry.max_retries;
let mut retry = StreamingRetryState::new();
loop {
let stream_ids = LlmStreamIds {
message_id: message_id.as_str(),
thinking_id: thinking_id.as_str(),
};
let result = process_stream(
provider,
&request,
event_ctx,
stream_ids,
#[cfg(feature = "otel")]
span_observer.as_mut(),
)
.await;
match result {
Ok(mut response) => {
finish_retry_events(event_ctx, retry.last_emitted).await;
add_usage(&mut response.usage, &retry.abandoned_usage);
return (LlmOutcome::Response(response), retry.total);
}
Err(StreamError::Connectivity { message, usage }) => {
let terminal = handle_connectivity_loss(
provider,
config,
event_ctx,
&mut retry,
&message,
&usage,
#[cfg(feature = "otel")]
span_observer.as_mut(),
)
.await;
if let Some(outcome) = terminal {
return outcome;
}
renew_stream_ids(message_id, thinking_id);
}
Err(StreamError::Recoverable {
message,
retry_after,
usage,
}) => {
let emits_transient = retry.record_transient_failure(&usage, max_retries);
mark_retry_emitted(&mut retry.last_emitted, retry.total, emits_transient);
let (failure_message, event_max_retries) =
transient_retry_context(&message, max_retries, retry.had_connectivity);
let step = handle_retry_backoff(RetryBackoff {
event_ctx,
config,
attempt: retry.transient,
event_attempt: retry.total,
settled_event_attempt: retry.last_emitted,
max_retries,
event_max_retries,
error_kind: "stream_error",
retry_reason: message,
failure_message,
override_delay: retry_after,
#[cfg(feature = "otel")]
span_observer: span_observer.as_mut(),
#[cfg(not(feature = "otel"))]
_observer: std::marker::PhantomData,
})
.await;
if let Some(outcome) =
terminal_retry_outcome(step, retry.total, &retry.abandoned_usage)
{
return outcome;
}
renew_stream_ids(message_id, thinking_id);
}
Err(StreamError::Fatal(message)) => {
return (fatal_stream_outcome(&message), retry.total);
}
Err(StreamError::Cancelled(usage)) => {
add_usage(&mut retry.abandoned_usage, &usage);
return (LlmOutcome::Cancelled(retry.abandoned_usage), retry.total);
}
}
}
}
async fn process_stream<P, H>(
provider: &Arc<P>,
request: &ChatRequest,
event_ctx: &LlmEventContext<'_, H>,
stream_ids: LlmStreamIds<'_>,
#[cfg(feature = "otel")] mut span_observer: Option<&mut LlmSpanObserver<'_>>,
) -> Result<ChatResponse, StreamError>
where
P: LlmProvider,
H: AgentHooks,
{
let mut stream = std::pin::pin!(provider.chat_stream(request.clone()));
let mut accumulator = StreamAccumulator::new();
let mut delta_count: u64 = 0;
#[cfg(feature = "otel")]
let mut chunk_timing = StreamChunkTiming::start();
log::debug!("Starting to consume LLM stream");
loop {
let result = tokio::select! {
biased;
() = event_ctx.cancel_token.cancelled() => {
log::info!(
"LLM stream cancelled (turn={}, delta_count={delta_count})",
event_ctx.turn
);
#[cfg(feature = "otel")]
if let Some(observer) = span_observer.as_mut() {
observer.record_dropped("cancelled", delta_count, "cancelled");
}
return Err(StreamError::Cancelled(accumulated_usage(&accumulator)));
}
next = tokio::time::timeout(LLM_STREAM_INACTIVITY_TIMEOUT, stream.next()) => match next {
Ok(Some(result)) => result,
Ok(None) => break,
Err(_elapsed) => {
warn!(
"LLM stream inactivity timeout (turn={}, delta_count={delta_count})",
event_ctx.turn
);
#[cfg(feature = "otel")]
if let Some(observer) = span_observer.as_mut() {
observer.record_dropped("inactivity_timeout", delta_count, "stream_error");
}
return Err(StreamError::Connectivity {
message: "stream inactivity timeout: no frame within deadline".to_string(),
usage: accumulated_usage(&accumulator),
});
}
},
};
if delta_count > 0 && delta_count.is_multiple_of(50) {
log::debug!("Stream progress: delta_count={delta_count}");
}
let delta = match result {
Ok(d) => d,
Err(e) => {
log::error!("Stream iteration error delta_count={delta_count} error={e}");
#[cfg(feature = "otel")]
if let Some(observer) = span_observer.as_mut() {
observer.record_dropped("recoverable_error", delta_count, "stream_error");
}
return Err(StreamError::Recoverable {
message: format!("Stream error: {e}"),
retry_after: None,
usage: accumulated_usage(&accumulator),
});
}
};
delta_count += 1;
accumulator.apply(&delta);
#[cfg(feature = "otel")]
if is_content_delta(&delta) {
chunk_timing.record_content_delta(span_observer.as_deref_mut(), event_ctx.turn);
}
if let Some(stream_err) = dispatch_stream_delta(
&delta,
event_ctx,
stream_ids,
delta_count,
#[cfg(feature = "otel")]
span_observer.as_deref_mut(),
)
.await
{
return Err(stream_err.with_accumulated_usage(accumulated_usage(&accumulator)));
}
}
log::debug!("Stream while loop exited normally at delta_count={delta_count}");
#[cfg(feature = "otel")]
chunk_timing.record_completed(span_observer, delta_count);
Ok(finalize_stream_response(
accumulator,
provider.model(),
delta_count,
))
}
#[cfg(feature = "otel")]
struct StreamChunkTiming {
stream_started_at: std::time::Instant,
first_chunk_recorded: bool,
last_chunk_at: Option<std::time::Instant>,
}
#[cfg(feature = "otel")]
impl StreamChunkTiming {
fn start() -> Self {
Self {
stream_started_at: std::time::Instant::now(),
first_chunk_recorded: false,
last_chunk_at: None,
}
}
fn record_content_delta(
&mut self,
mut observer: Option<&mut LlmSpanObserver<'_>>,
turn: usize,
) {
let now = std::time::Instant::now();
if !self.first_chunk_recorded {
self.first_chunk_recorded = true;
if let Some(observer) = observer.as_deref_mut() {
let ttfc_secs = self.stream_started_at.elapsed().as_secs_f64();
observer.record_first_chunk(turn, true, ttfc_secs);
}
} else if let Some(prev) = self.last_chunk_at
&& let Some(observer) = observer
{
let tpoc_secs = now.duration_since(prev).as_secs_f64();
observer.record_subsequent_chunk(tpoc_secs);
}
self.last_chunk_at = Some(now);
}
fn record_completed(&self, observer: Option<&mut LlmSpanObserver<'_>>, delta_count: u64) {
if let Some(observer) = observer {
let duration_ms =
u64::try_from(self.stream_started_at.elapsed().as_millis()).unwrap_or(u64::MAX);
observer.record_completed(delta_count, duration_ms);
}
}
}
const ZERO_USAGE: Usage = Usage {
input_tokens: 0,
output_tokens: 0,
cached_input_tokens: 0,
cache_creation_input_tokens: 0,
};
fn accumulated_usage(accumulator: &StreamAccumulator) -> Usage {
accumulator.usage().cloned().unwrap_or(ZERO_USAGE)
}
const fn add_usage(total: &mut Usage, delta: &Usage) {
total.input_tokens = total.input_tokens.saturating_add(delta.input_tokens);
total.output_tokens = total.output_tokens.saturating_add(delta.output_tokens);
total.cached_input_tokens = total
.cached_input_tokens
.saturating_add(delta.cached_input_tokens);
total.cache_creation_input_tokens = total
.cache_creation_input_tokens
.saturating_add(delta.cache_creation_input_tokens);
}
fn finalize_stream_response(
accumulator: StreamAccumulator,
model: &str,
delta_count: u64,
) -> ChatResponse {
let usage = accumulated_usage(&accumulator);
let stop_reason = accumulator.stop_reason().copied();
let content_blocks = accumulator.into_content_blocks();
log::debug!(
"LLM stream completed successfully delta_count={delta_count} stop_reason={stop_reason:?} content_block_count={} input_tokens={} output_tokens={}",
content_blocks.len(),
usage.input_tokens,
usage.output_tokens
);
ChatResponse {
id: uuid::Uuid::new_v4().to_string(),
content: content_blocks,
model: model.to_string(),
stop_reason,
usage,
}
}
async fn dispatch_stream_delta<H>(
delta: &StreamDelta,
event_ctx: &LlmEventContext<'_, H>,
stream_ids: LlmStreamIds<'_>,
delta_count: u64,
#[cfg(feature = "otel")] mut span_observer: Option<&mut LlmSpanObserver<'_>>,
) -> Option<StreamError>
where
H: AgentHooks,
{
let LlmStreamIds {
message_id,
thinking_id,
} = stream_ids;
match delta {
StreamDelta::TextDelta { delta, .. } => {
if let Err(error) = send_event(
event_ctx.event_store,
event_ctx.thread_id,
event_ctx.turn,
event_ctx.hooks,
event_ctx.authority,
AgentEvent::text_delta(message_id, delta.clone()),
)
.await
{
#[cfg(feature = "otel")]
if let Some(observer) = span_observer.as_mut() {
observer.record_dropped(
"event_channel_send_failed",
delta_count,
"event_channel",
);
}
return Some(StreamError::Fatal(error.message));
}
}
StreamDelta::ThinkingDelta { delta, .. } => {
if let Err(error) = send_event(
event_ctx.event_store,
event_ctx.thread_id,
event_ctx.turn,
event_ctx.hooks,
event_ctx.authority,
AgentEvent::thinking_delta(thinking_id, delta.clone()),
)
.await
{
#[cfg(feature = "otel")]
if let Some(observer) = span_observer.as_mut() {
observer.record_dropped(
"event_channel_send_failed",
delta_count,
"event_channel",
);
}
return Some(StreamError::Fatal(error.message));
}
}
StreamDelta::Error { message, kind } => {
log::warn!(
"Stream error received delta_count={delta_count} message={message} kind={kind:?}"
);
let recoverable = kind.is_recoverable();
#[cfg(feature = "otel")]
if let Some(observer) = span_observer.as_mut() {
let reason = if recoverable {
"recoverable_error"
} else {
"fatal_error"
};
observer.record_dropped(reason, delta_count, stream_error_kind_attr(*kind));
}
return Some(if kind.is_connectivity() {
StreamError::Connectivity {
message: message.clone(),
usage: ZERO_USAGE,
}
} else if recoverable {
StreamError::Recoverable {
message: message.clone(),
retry_after: kind.retry_after(),
usage: ZERO_USAGE,
}
} else {
StreamError::Fatal(message.clone())
});
}
_ => {}
}
None
}
#[cfg(feature = "otel")]
const fn stream_error_kind_attr(kind: crate::llm::StreamErrorKind) -> &'static str {
match kind {
crate::llm::StreamErrorKind::Connectivity => "connectivity",
crate::llm::StreamErrorKind::ConnectionLost => "connection_lost",
crate::llm::StreamErrorKind::RateLimited(_) => "rate_limited",
crate::llm::StreamErrorKind::ServerError => "server_error",
crate::llm::StreamErrorKind::InvalidRequest => "invalid_request",
_ => "unknown",
}
}
#[cfg(feature = "otel")]
const fn is_content_delta(delta: &StreamDelta) -> bool {
matches!(
delta,
StreamDelta::TextDelta { .. }
| StreamDelta::ThinkingDelta { .. }
| StreamDelta::ToolUseStart { .. }
| StreamDelta::ToolInputDelta { .. }
| StreamDelta::SignatureDelta { .. }
| StreamDelta::RedactedThinking { .. }
)
}
async fn send_llm_error_event<H>(
event_ctx: &LlmEventContext<'_, H>,
error_msg: &str,
) -> Result<(), AgentError>
where
H: AgentHooks,
{
send_event(
event_ctx.event_store,
event_ctx.thread_id,
event_ctx.turn,
event_ctx.hooks,
event_ctx.authority,
AgentEvent::error(error_msg, true),
)
.await
}
async fn send_auto_retry_start_event<H>(
event_ctx: &LlmEventContext<'_, H>,
attempt: u32,
max_attempts: u32,
delay_ms: u64,
error_message: &str,
) where
H: AgentHooks,
{
let _ = send_event(
event_ctx.event_store,
event_ctx.thread_id,
event_ctx.turn,
event_ctx.hooks,
event_ctx.authority,
AgentEvent::AutoRetryStart {
attempt,
max_attempts,
delay_ms,
error_message: error_message.to_string(),
},
)
.await;
}
async fn send_auto_retry_end_event<H>(
event_ctx: &LlmEventContext<'_, H>,
attempt: u32,
success: bool,
final_error: Option<String>,
) where
H: AgentHooks,
{
let _ = send_event(
event_ctx.event_store,
event_ctx.thread_id,
event_ctx.turn,
event_ctx.hooks,
event_ctx.authority,
AgentEvent::AutoRetryEnd {
attempt,
success,
final_error,
},
)
.await;
}