use crate::{Message, Provider};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LoweredRequestEncoding {
AnthropicMessagesJson,
OpenAiResponsesJson,
OpenAiChatCompletionsJson,
GeminiGenerateContentJson,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub struct LoweredRequestProvenance {
pub provider: Provider,
pub encoding: LoweredRequestEncoding,
pub body_sha256: [u8; 32],
}
impl LoweredRequestProvenance {
pub fn from_body(
provider: Provider,
encoding: LoweredRequestEncoding,
encoded_body: &[u8],
) -> Self {
Self {
provider,
encoding,
body_sha256: Sha256::digest(encoded_body).into(),
}
}
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PresentedTokenConvention {
AnthropicDisjointInputComponents,
OpenAiInputIncludesCachedSubset,
GeminiPromptIncludesCachedSubset,
OpenAiCompatiblePromptIncludesCacheDetails,
HostDeclaredInclusiveInputTotal,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TokenAggregationProvenance {
SumDisjointProviderComponents,
ProviderInclusiveInputTotal,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub struct ProviderTokenAccounting {
pub provider: Provider,
pub model: String,
pub presented_tokens: u64,
pub convention: PresentedTokenConvention,
pub aggregation: TokenAggregationProvenance,
}
impl ProviderTokenAccounting {
pub fn anthropic(
model: impl Into<String>,
uncached_input: u64,
cache_creation_input: u64,
cache_read_input: u64,
) -> Self {
Self {
provider: Provider::Anthropic,
model: model.into(),
presented_tokens: uncached_input
.saturating_add(cache_creation_input)
.saturating_add(cache_read_input),
convention: PresentedTokenConvention::AnthropicDisjointInputComponents,
aggregation: TokenAggregationProvenance::SumDisjointProviderComponents,
}
}
pub fn openai(model: impl Into<String>, input_tokens: u64) -> Self {
Self {
provider: Provider::OpenAI,
model: model.into(),
presented_tokens: input_tokens,
convention: PresentedTokenConvention::OpenAiInputIncludesCachedSubset,
aggregation: TokenAggregationProvenance::ProviderInclusiveInputTotal,
}
}
pub fn gemini(model: impl Into<String>, prompt_tokens: u64) -> Self {
Self {
provider: Provider::Gemini,
model: model.into(),
presented_tokens: prompt_tokens,
convention: PresentedTokenConvention::GeminiPromptIncludesCachedSubset,
aggregation: TokenAggregationProvenance::ProviderInclusiveInputTotal,
}
}
pub fn openai_compatible(model: impl Into<String>, prompt_tokens: u64) -> Self {
Self::openai_compatible_for(Provider::SelfHosted, model, prompt_tokens)
}
pub fn openai_compatible_for(
provider: Provider,
model: impl Into<String>,
prompt_tokens: u64,
) -> Self {
Self {
provider,
model: model.into(),
presented_tokens: prompt_tokens,
convention: PresentedTokenConvention::OpenAiCompatiblePromptIncludesCacheDetails,
aggregation: TokenAggregationProvenance::ProviderInclusiveInputTotal,
}
}
pub fn host_declared(provider: Provider, model: impl Into<String>, input_tokens: u64) -> Self {
Self {
provider,
model: model.into(),
presented_tokens: input_tokens,
convention: PresentedTokenConvention::HostDeclaredInclusiveInputTotal,
aggregation: TokenAggregationProvenance::ProviderInclusiveInputTotal,
}
}
}
pub const UNMEASURED_MARKER_PREFIX: &str = "unmeasured:";
pub const DISPUTED_MARKER_PREFIX: &str = "disputed:";
pub const TURN_USAGE_ACCOUNTING_DIMENSION: &str = "turn_usage_accounting";
pub const TURN_USAGE_ACCOUNTING_IDENTITY_DIMENSION: &str = "turn_usage_accounting_identity";
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub struct UnmeasuredTurnUsageAccounting {
pub provider: Provider,
pub model: String,
}
impl UnmeasuredTurnUsageAccounting {
pub const MARKER: &'static str = "unmeasured:turn_usage_accounting";
pub fn new(provider: Provider, model: impl Into<String>) -> Self {
Self {
provider,
model: model.into(),
}
}
pub const fn marker(&self) -> &'static str {
Self::MARKER
}
}
impl std::fmt::Display for UnmeasuredTurnUsageAccounting {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}{{provider={}, model={}}}",
Self::MARKER,
self.provider.as_str(),
self.model
)
}
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub struct DisputedTurnUsageAccountingIdentity {
pub active_provider: Provider,
pub active_model: String,
pub reported_provider: Provider,
pub reported_model: String,
}
impl DisputedTurnUsageAccountingIdentity {
pub const MARKER: &'static str = "disputed:turn_usage_accounting_identity";
pub const fn marker(&self) -> &'static str {
Self::MARKER
}
}
impl std::fmt::Display for DisputedTurnUsageAccountingIdentity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}{{active={}/{}, reported={}/{}}}",
Self::MARKER,
self.active_provider.as_str(),
self.active_model,
self.reported_provider.as_str(),
self.reported_model
)
}
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum CacheBreakpointBoundary {
SystemProfilePrefix { message_count: u64 },
TranscriptAfter { message_count: u64 },
}
impl CacheBreakpointBoundary {
pub const fn message_count(self) -> u64 {
match self {
Self::SystemProfilePrefix { message_count }
| Self::TranscriptAfter { message_count } => message_count,
}
}
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProviderCacheTtl {
FiveMinutes,
OneHour,
ThirtyMinutes,
TwentyFourHours,
ProviderDefault,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub struct AuthoredCacheBreakpoint {
provider: Provider,
model: String,
boundary: CacheBreakpointBoundary,
canonical_prefix_sha256: String,
canonical_prefix_bytes: u64,
rendered_prefix_sha256: String,
rendered_prefix_bytes: u64,
lowered_request_provenance: LoweredRequestProvenance,
ttl: ProviderCacheTtl,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum CacheBreakpointEvidenceError {
#[error("cache breakpoint boundary {message_count} exceeds transcript length {message_len}")]
BoundaryOutOfRange {
message_count: u64,
message_len: usize,
},
#[error("cache breakpoint prefix could not be canonically encoded: {detail}")]
CanonicalEncodingFailed { detail: String },
#[error("persisted cache-breakpoint evidence is malformed: {detail}")]
PersistedEvidenceMalformed { detail: String },
#[error("cache-breakpoint evidence does not match the canonical transcript prefix")]
CanonicalPrefixMismatch,
#[error("cache-breakpoint rendered-prefix evidence is malformed")]
RenderedPrefixMalformed,
#[error("cache-breakpoint provider and lowered-request encoding are incoherent")]
ProviderEncodingMismatch,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
#[non_exhaustive]
pub enum CacheBreakpointDiscardReason {
BoundaryOutsideCommittedTranscript {
message_count: u64,
message_len: u64,
},
CanonicalPrefixMoved,
EvidenceUnusable { detail: String },
ProjectedBoundaryUnmappable,
}
impl CacheBreakpointDiscardReason {
pub const fn code(&self) -> &'static str {
match self {
Self::BoundaryOutsideCommittedTranscript { .. } => {
"boundary_outside_committed_transcript"
}
Self::CanonicalPrefixMoved => "canonical_prefix_moved",
Self::EvidenceUnusable { .. } => "evidence_unusable",
Self::ProjectedBoundaryUnmappable => "projected_boundary_unmappable",
}
}
}
impl std::fmt::Display for CacheBreakpointDiscardReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::BoundaryOutsideCommittedTranscript {
message_count,
message_len,
} => write!(
f,
"anchored boundary {message_count} is outside committed transcript length {message_len}"
),
Self::CanonicalPrefixMoved => {
f.write_str("committed transcript prefix moved under the anchored boundary")
}
Self::EvidenceUnusable { detail } => {
write!(f, "cache-breakpoint proof is unusable: {detail}")
}
Self::ProjectedBoundaryUnmappable => {
f.write_str("durable prompt-version history has no raw-to-projected boundary map")
}
}
}
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CacheBreakpointDiscardOrigin {
AuthoredThisTurn,
PersistedEvidence,
}
impl CacheBreakpointDiscardOrigin {
pub const fn as_str(self) -> &'static str {
match self {
Self::AuthoredThisTurn => "authored_this_turn",
Self::PersistedEvidence => "persisted_evidence",
}
}
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub struct DiscardedCacheBreakpointIdentity {
pub provider: Provider,
pub model: String,
pub boundary: CacheBreakpointBoundary,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub struct DiscardedCacheBreakpoint {
origin: CacheBreakpointDiscardOrigin,
#[serde(default, skip_serializing_if = "Option::is_none")]
identity: Option<DiscardedCacheBreakpointIdentity>,
reason: CacheBreakpointDiscardReason,
}
impl DiscardedCacheBreakpoint {
pub(crate) fn proof(
origin: CacheBreakpointDiscardOrigin,
breakpoint: &AuthoredCacheBreakpoint,
reason: CacheBreakpointDiscardReason,
) -> Self {
Self {
origin,
identity: Some(DiscardedCacheBreakpointIdentity {
provider: breakpoint.provider(),
model: breakpoint.model().to_string(),
boundary: breakpoint.boundary(),
}),
reason,
}
}
pub(crate) const fn persisted_row(reason: CacheBreakpointDiscardReason) -> Self {
Self {
origin: CacheBreakpointDiscardOrigin::PersistedEvidence,
identity: None,
reason,
}
}
pub const fn origin(&self) -> CacheBreakpointDiscardOrigin {
self.origin
}
pub const fn identity(&self) -> Option<&DiscardedCacheBreakpointIdentity> {
self.identity.as_ref()
}
pub const fn reason(&self) -> &CacheBreakpointDiscardReason {
&self.reason
}
}
impl std::fmt::Display for DiscardedCacheBreakpoint {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match &self.identity {
Some(identity) => write!(
f,
"provider={} model={} boundary={} ({}): {}",
identity.provider.as_str(),
identity.model,
identity.boundary.message_count(),
self.origin.as_str(),
self.reason
),
None => write!(
f,
"persisted cache-breakpoint evidence row ({}): {}",
self.origin.as_str(),
self.reason
),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AuthoredCacheBreakpointRetention {
retained: usize,
discarded: Vec<DiscardedCacheBreakpoint>,
}
impl AuthoredCacheBreakpointRetention {
pub const fn retained(&self) -> usize {
self.retained
}
pub fn discarded(&self) -> &[DiscardedCacheBreakpoint] {
&self.discarded
}
pub fn into_discarded(self) -> Vec<DiscardedCacheBreakpoint> {
self.discarded
}
pub fn is_degraded(&self) -> bool {
!self.discarded.is_empty()
}
pub(crate) fn set_retained(&mut self, retained: usize) {
self.retained = retained;
}
pub(crate) fn push_discard(&mut self, discard: DiscardedCacheBreakpoint) {
self.discarded.push(discard);
}
}
pub(crate) fn classify_cache_breakpoint_binding_failure(
error: CacheBreakpointEvidenceError,
) -> Result<CacheBreakpointDiscardReason, CacheBreakpointEvidenceError> {
match error {
CacheBreakpointEvidenceError::BoundaryOutOfRange {
message_count,
message_len,
} => Ok(
CacheBreakpointDiscardReason::BoundaryOutsideCommittedTranscript {
message_count,
message_len: message_len as u64,
},
),
CacheBreakpointEvidenceError::CanonicalPrefixMismatch => {
Ok(CacheBreakpointDiscardReason::CanonicalPrefixMoved)
}
error @ (CacheBreakpointEvidenceError::RenderedPrefixMalformed
| CacheBreakpointEvidenceError::ProviderEncodingMismatch) => {
Ok(CacheBreakpointDiscardReason::EvidenceUnusable {
detail: error.to_string(),
})
}
error @ (CacheBreakpointEvidenceError::CanonicalEncodingFailed { .. }
| CacheBreakpointEvidenceError::PersistedEvidenceMalformed { .. }) => Err(error),
}
}
impl AuthoredCacheBreakpoint {
pub(crate) fn from_provider_claim(claim: ProviderCacheBreakpointClaim) -> Self {
claim.evidence
}
pub const fn provider(&self) -> Provider {
self.provider
}
pub fn model(&self) -> &str {
&self.model
}
pub const fn boundary(&self) -> CacheBreakpointBoundary {
self.boundary
}
pub fn canonical_prefix_sha256(&self) -> &str {
&self.canonical_prefix_sha256
}
pub const fn canonical_prefix_bytes(&self) -> u64 {
self.canonical_prefix_bytes
}
pub fn rendered_prefix_sha256(&self) -> &str {
&self.rendered_prefix_sha256
}
pub const fn rendered_prefix_bytes(&self) -> u64 {
self.rendered_prefix_bytes
}
pub const fn lowered_request_provenance(&self) -> LoweredRequestProvenance {
self.lowered_request_provenance
}
pub const fn ttl(&self) -> ProviderCacheTtl {
self.ttl
}
pub fn validate_rendered_identity(&self) -> Result<(), CacheBreakpointEvidenceError> {
let hash = self.rendered_prefix_sha256.as_bytes();
let valid_hash = hash.len() == 71
&& hash.starts_with(b"sha256:")
&& hash[7..]
.iter()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(byte));
if !valid_hash || self.rendered_prefix_bytes == 0 {
return Err(CacheBreakpointEvidenceError::RenderedPrefixMalformed);
}
let coherent_encoding = matches!(
(self.provider, self.lowered_request_provenance.encoding),
(
Provider::Anthropic,
LoweredRequestEncoding::AnthropicMessagesJson
) | (
Provider::Gemini,
LoweredRequestEncoding::GeminiGenerateContentJson
) | (
Provider::OpenAI | Provider::SelfHosted,
LoweredRequestEncoding::OpenAiResponsesJson
| LoweredRequestEncoding::OpenAiChatCompletionsJson
)
) && self.lowered_request_provenance.provider == self.provider;
if !coherent_encoding {
return Err(CacheBreakpointEvidenceError::ProviderEncodingMismatch);
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct ProviderCacheBreakpointClaim {
evidence: AuthoredCacheBreakpoint,
}
impl ProviderCacheBreakpointClaim {
pub const fn provider(&self) -> Provider {
self.evidence.provider()
}
pub fn model(&self) -> &str {
self.evidence.model()
}
}
#[derive(Debug)]
pub struct ValidatedSourceCacheBreakpoint {
evidence: AuthoredCacheBreakpoint,
}
impl ValidatedSourceCacheBreakpoint {
pub(crate) fn new(evidence: AuthoredCacheBreakpoint) -> Self {
Self { evidence }
}
pub const fn provider(&self) -> Provider {
self.evidence.provider()
}
pub fn model(&self) -> &str {
self.evidence.model()
}
pub const fn boundary(&self) -> CacheBreakpointBoundary {
self.evidence.boundary()
}
pub(crate) fn into_authored_evidence(self) -> AuthoredCacheBreakpoint {
self.evidence
}
}
#[derive(Debug)]
pub struct TargetCacheLoweringCapability {
evidence: AuthoredCacheBreakpoint,
}
impl TargetCacheLoweringCapability {
pub const fn provider(&self) -> Provider {
self.evidence.provider()
}
pub fn model(&self) -> &str {
self.evidence.model()
}
pub const fn boundary(&self) -> CacheBreakpointBoundary {
self.evidence.boundary()
}
pub fn rendered_prefix_sha256(&self) -> &str {
self.evidence.rendered_prefix_sha256()
}
pub const fn rendered_prefix_bytes(&self) -> u64 {
self.evidence.rendered_prefix_bytes()
}
pub const fn lowered_request_provenance(&self) -> LoweredRequestProvenance {
self.evidence.lowered_request_provenance()
}
pub const fn ttl(&self) -> ProviderCacheTtl {
self.evidence.ttl()
}
pub(crate) fn into_authored_evidence(self) -> AuthoredCacheBreakpoint {
self.evidence
}
}
#[derive(Debug)]
pub struct TargetCacheLoweringIssuer {
_private: (),
}
impl TargetCacheLoweringIssuer {
pub(crate) const fn new() -> Self {
Self { _private: () }
}
pub fn mint(
&self,
claim: ProviderCacheBreakpointClaim,
) -> Result<TargetCacheLoweringCapability, CacheBreakpointEvidenceError> {
let evidence = AuthoredCacheBreakpoint::from_provider_claim(claim);
evidence.validate_rendered_identity()?;
Ok(TargetCacheLoweringCapability { evidence })
}
}
pub fn canonical_cache_prefix_identity(
messages: &[Message],
message_count: u64,
) -> Result<(String, u64), CacheBreakpointEvidenceError> {
let boundary = usize::try_from(message_count).map_err(|_| {
CacheBreakpointEvidenceError::BoundaryOutOfRange {
message_count,
message_len: messages.len(),
}
})?;
let prefix =
messages
.get(..boundary)
.ok_or(CacheBreakpointEvidenceError::BoundaryOutOfRange {
message_count,
message_len: messages.len(),
})?;
crate::session::canonical_transcript_prefix_identity(prefix).map_err(|error| {
CacheBreakpointEvidenceError::CanonicalEncodingFailed {
detail: error.to_string(),
}
})
}
pub struct ProviderCacheBreakpointClaimRequest<'a> {
pub provider: Provider,
pub model: &'a str,
pub messages: &'a [Message],
pub boundary: CacheBreakpointBoundary,
pub ttl: ProviderCacheTtl,
pub rendered_prefix: &'a [u8],
pub lowered_request_encoding: LoweredRequestEncoding,
pub lowered_request_body: &'a [u8],
}
pub fn provider_cache_breakpoint_claim(
request: ProviderCacheBreakpointClaimRequest<'_>,
) -> Result<ProviderCacheBreakpointClaim, CacheBreakpointEvidenceError> {
let (canonical_prefix_sha256, canonical_prefix_bytes) =
canonical_cache_prefix_identity(request.messages, request.boundary.message_count())?;
let rendered_prefix_sha256 = format!("sha256:{:x}", Sha256::digest(request.rendered_prefix));
let rendered_prefix_bytes = u64::try_from(request.rendered_prefix.len()).unwrap_or(u64::MAX);
let lowered_request_provenance = LoweredRequestProvenance::from_body(
request.provider,
request.lowered_request_encoding,
request.lowered_request_body,
);
let evidence = AuthoredCacheBreakpoint {
provider: request.provider,
model: request.model.to_string(),
boundary: request.boundary,
canonical_prefix_sha256,
canonical_prefix_bytes,
rendered_prefix_sha256,
rendered_prefix_bytes,
lowered_request_provenance,
ttl: request.ttl,
};
evidence.validate_rendered_identity()?;
Ok(ProviderCacheBreakpointClaim { evidence })
}
#[cfg(test)]
mod degradation_marker_tests {
use super::{
DISPUTED_MARKER_PREFIX, DisputedTurnUsageAccountingIdentity,
TURN_USAGE_ACCOUNTING_DIMENSION, TURN_USAGE_ACCOUNTING_IDENTITY_DIMENSION,
UNMEASURED_MARKER_PREFIX, UnmeasuredTurnUsageAccounting,
};
use crate::Provider;
#[test]
fn unmeasured_turn_usage_marker_composes_from_the_shared_vocabulary() {
assert_eq!(
UnmeasuredTurnUsageAccounting::MARKER,
format!("{UNMEASURED_MARKER_PREFIX}{TURN_USAGE_ACCOUNTING_DIMENSION}")
);
assert_eq!(
DisputedTurnUsageAccountingIdentity::MARKER,
format!("{DISPUTED_MARKER_PREFIX}{TURN_USAGE_ACCOUNTING_IDENTITY_DIMENSION}")
);
}
#[test]
fn unmeasured_turn_usage_renders_the_request_identity() {
let unmeasured = UnmeasuredTurnUsageAccounting::new(Provider::Anthropic, "claude-opus-5");
assert_eq!(
unmeasured.to_string(),
"unmeasured:turn_usage_accounting{provider=anthropic, model=claude-opus-5}"
);
assert_eq!(unmeasured.marker(), UnmeasuredTurnUsageAccounting::MARKER);
}
#[test]
fn disputed_identity_renders_both_sides() {
let dispute = DisputedTurnUsageAccountingIdentity {
active_provider: Provider::OpenAI,
active_model: "active-model".to_string(),
reported_provider: Provider::OpenAI,
reported_model: "reported-model".to_string(),
};
assert_eq!(
dispute.to_string(),
"disputed:turn_usage_accounting_identity{active=openai/active-model, \
reported=openai/reported-model}"
);
}
}