use std::time::{Duration, Instant};
use aws_sdk_bedrockruntime::types::TokenUsage;
use serde::Serialize;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum InvocationOutcome {
Completed,
Rejected,
Failed,
Cancelled,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct InvocationUsage {
pub input_tokens: Option<u64>,
pub output_tokens: Option<u64>,
pub cache_read_input_tokens: Option<u64>,
pub cache_write_input_tokens: Option<u64>,
pub reasoning_tokens: Option<u64>,
}
impl InvocationUsage {
pub(super) fn from_bedrock(value: &TokenUsage) -> Self {
Self {
input_tokens: u64::try_from(value.input_tokens).ok(),
output_tokens: u64::try_from(value.output_tokens).ok(),
cache_read_input_tokens: value
.cache_read_input_tokens
.and_then(|n| u64::try_from(n).ok()),
cache_write_input_tokens: value
.cache_write_input_tokens
.and_then(|n| u64::try_from(n).ok()),
reasoning_tokens: None,
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct BedrockInvocation {
pub requested_model: String,
pub dispatched_target: String,
pub api: &'static str,
pub endpoint: &'static str,
pub region: Option<String>,
pub provider_reported_model: Option<String>,
pub request_id: Option<String>,
pub provider_stop_reason: Option<String>,
pub outcome: InvocationOutcome,
pub attempts: usize,
pub sdk_retry_count: Option<usize>,
pub elapsed_ms: u64,
pub provider_latency_ms: Option<u64>,
pub usage: Option<InvocationUsage>,
}
impl BedrockInvocation {
pub(super) fn new(
requested_model: &str,
target: String,
api: &'static str,
region: Option<String>,
) -> Self {
Self {
requested_model: requested_model.into(),
dispatched_target: target,
api,
endpoint: "bedrock-runtime",
region,
provider_reported_model: None,
request_id: None,
provider_stop_reason: None,
outcome: InvocationOutcome::Failed,
attempts: 0,
sdk_retry_count: None,
elapsed_ms: 0,
provider_latency_ms: None,
usage: None,
}
}
pub(super) fn finish(&mut self, started: Instant, attempts: usize, outcome: InvocationOutcome) {
self.elapsed_ms = millis(started.elapsed());
self.attempts = attempts;
self.outcome = outcome;
}
}
fn millis(duration: Duration) -> u64 {
duration.as_millis().try_into().unwrap_or(u64::MAX)
}
pub(super) fn stop_outcome(reason: &str) -> InvocationOutcome {
match reason {
"end_turn" | "stop_sequence" | "tool_use" | "pause_turn" => InvocationOutcome::Completed,
_ => InvocationOutcome::Rejected,
}
}
pub(super) struct InvocationGuard {
pub record: BedrockInvocation,
pub attempts: std::sync::Arc<std::sync::atomic::AtomicUsize>,
pub last_request_id: std::sync::Arc<std::sync::Mutex<Option<String>>>,
started: Instant,
callback: Option<std::sync::Arc<dyn Fn(BedrockInvocation) + Send + Sync>>,
emitted: bool,
}
impl InvocationGuard {
pub fn new(
mut record: BedrockInvocation,
callback: Option<std::sync::Arc<dyn Fn(BedrockInvocation) + Send + Sync>>,
sdk_retry_count: Option<usize>,
) -> Self {
record.sdk_retry_count = sdk_retry_count;
Self {
record,
attempts: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)),
last_request_id: std::sync::Arc::new(std::sync::Mutex::new(None)),
started: Instant::now(),
callback,
emitted: false,
}
}
pub fn finish(&mut self, outcome: InvocationOutcome) {
if self.emitted {
return;
}
self.emitted = true;
self.record.request_id = self.record.request_id.clone().or_else(|| {
self.last_request_id
.lock()
.unwrap_or_else(|error| error.into_inner())
.clone()
});
self.record.finish(
self.started,
self.attempts.load(std::sync::atomic::Ordering::Relaxed),
outcome,
);
if let Some(callback) = &self.callback {
callback(self.record.clone());
}
}
}
impl Drop for InvocationGuard {
fn drop(&mut self) {
if self.attempts.load(std::sync::atomic::Ordering::Relaxed) > 0 {
self.finish(InvocationOutcome::Cancelled);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn missing_usage_and_provider_identity_are_not_fabricated() {
let record = BedrockInvocation::new(
"anthropic.claude-opus-5",
"us.anthropic.claude-opus-5".into(),
"converse",
None,
);
assert!(record.provider_reported_model.is_none());
assert!(record.usage.is_none());
assert!(record.sdk_retry_count.is_none());
let usage = TokenUsage::builder()
.input_tokens(10)
.output_tokens(2)
.total_tokens(12)
.build()
.unwrap();
let usage = InvocationUsage::from_bedrock(&usage);
assert_eq!(usage.input_tokens, Some(10));
assert!(usage.cache_read_input_tokens.is_none());
assert!(usage.cache_write_input_tokens.is_none());
assert!(usage.reasoning_tokens.is_none());
}
#[test]
fn explicit_zero_usage_is_preserved() {
let usage = TokenUsage::builder()
.input_tokens(0)
.output_tokens(0)
.total_tokens(0)
.cache_read_input_tokens(0)
.cache_write_input_tokens(0)
.build()
.unwrap();
let usage = InvocationUsage::from_bedrock(&usage);
assert_eq!(usage.input_tokens, Some(0));
assert_eq!(usage.cache_read_input_tokens, Some(0));
assert_eq!(usage.cache_write_input_tokens, Some(0));
assert_eq!(stop_outcome("refusal"), InvocationOutcome::Rejected);
assert_eq!(
stop_outcome("guardrail_intervened"),
InvocationOutcome::Rejected
);
assert_eq!(stop_outcome("max_tokens"), InvocationOutcome::Rejected);
}
}