mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-12 22:20:19 +08:00
Merge remote-tracking branch 'origin/pr/614'
This commit is contained in:
@@ -1,14 +1,21 @@
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::fmt::Write as _;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use aether_runtime_state::{DataLayerError, RuntimeState};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use sha2::{Digest, Sha256};
|
||||
use tracing::warn;
|
||||
|
||||
use crate::clock::current_unix_ms;
|
||||
|
||||
const DEFAULT_CACHE_TTL: Duration = Duration::from_secs(300);
|
||||
const ONE_HOUR_CACHE_TTL: Duration = Duration::from_secs(3600);
|
||||
const MAX_ENTRIES: usize = 2048;
|
||||
const PREFIX_LOOKBACK_LIMIT: usize = 10;
|
||||
const KIRO_PROMPT_CACHE_INDEX_KEY: &str = "kiro:prompt-cache:index";
|
||||
const PREFIX_LOOKBACK_WINDOW: usize = 20;
|
||||
const TOKENS_PER_TOOL: u64 = 150;
|
||||
const TOKENS_PER_MESSAGE: u64 = 4;
|
||||
const INLINE_IMAGE_DATA_TOKEN_PLACEHOLDER: &str = "[inline-image-data]";
|
||||
@@ -21,6 +28,7 @@ pub(crate) struct KiroPromptCacheProfile {
|
||||
total_input_tokens: u64,
|
||||
min_cacheable_tokens: u64,
|
||||
breakpoints: Vec<KiroPromptCacheBreakpoint>,
|
||||
match_candidates: Vec<KiroPromptCacheCandidate>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -37,6 +45,12 @@ struct KiroPromptCacheEntry {
|
||||
expires_at: Instant,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
|
||||
struct KiroPromptCacheRuntimeEntry {
|
||||
token_count: u64,
|
||||
ttl_secs: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub(crate) struct KiroPromptCacheUsage {
|
||||
pub(crate) cache_creation_input_tokens: u64,
|
||||
@@ -53,11 +67,10 @@ struct PendingBlock {
|
||||
value: Value,
|
||||
tokens: u64,
|
||||
breakpoint_ttl: Option<Duration>,
|
||||
is_message_end: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
struct PrefixCandidate {
|
||||
struct KiroPromptCacheCandidate {
|
||||
fingerprint: [u8; 32],
|
||||
cumulative_tokens: u64,
|
||||
}
|
||||
@@ -66,6 +79,230 @@ pub(crate) fn kiro_prompt_cache_tracker() -> &'static KiroPromptCacheTracker {
|
||||
KIRO_PROMPT_CACHE_TRACKER.get_or_init(KiroPromptCacheTracker::default)
|
||||
}
|
||||
|
||||
pub(crate) async fn compute_kiro_prompt_cache_usage(
|
||||
runtime_state: &RuntimeState,
|
||||
credential_id: String,
|
||||
profile: &KiroPromptCacheProfile,
|
||||
) -> KiroPromptCacheUsage {
|
||||
match compute_kiro_prompt_cache_usage_with_runtime_state(
|
||||
runtime_state,
|
||||
credential_id.as_str(),
|
||||
profile,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(usage) => usage,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
event_name = "kiro_simulated_cache_runtime_state_failed",
|
||||
log_type = "event",
|
||||
error = ?err,
|
||||
"failed to update Kiro simulated cache runtime state; falling back to process-local tracker"
|
||||
);
|
||||
kiro_prompt_cache_tracker().compute_and_update(credential_id, profile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn compute_kiro_prompt_cache_usage_with_runtime_state(
|
||||
runtime_state: &RuntimeState,
|
||||
credential_id: &str,
|
||||
profile: &KiroPromptCacheProfile,
|
||||
) -> Result<KiroPromptCacheUsage, DataLayerError> {
|
||||
let last_breakpoint = match profile.breakpoints.last().copied() {
|
||||
Some(last_breakpoint) => last_breakpoint,
|
||||
None => return Ok(KiroPromptCacheUsage::default()),
|
||||
};
|
||||
|
||||
let reversed_candidates = profile
|
||||
.match_candidates
|
||||
.iter()
|
||||
.rev()
|
||||
.copied()
|
||||
.collect::<Vec<_>>();
|
||||
let candidate_keys = reversed_candidates
|
||||
.iter()
|
||||
.map(|candidate| kiro_prompt_cache_runtime_key(credential_id, &candidate.fingerprint))
|
||||
.collect::<Vec<_>>();
|
||||
let candidate_values = runtime_state.kv_get_many(&candidate_keys).await?;
|
||||
let mut existing_entries = HashMap::<String, KiroPromptCacheRuntimeEntry>::new();
|
||||
let mut matched_tokens = 0u64;
|
||||
let mut matched_refresh: Option<(String, KiroPromptCacheRuntimeEntry)> = None;
|
||||
|
||||
for ((candidate, key), value) in reversed_candidates
|
||||
.iter()
|
||||
.zip(candidate_keys.iter())
|
||||
.zip(candidate_values)
|
||||
{
|
||||
let Some(entry) = value
|
||||
.as_deref()
|
||||
.and_then(parse_kiro_prompt_cache_runtime_entry)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
existing_entries.insert(key.clone(), entry);
|
||||
if matched_tokens == 0 {
|
||||
matched_tokens = entry
|
||||
.token_count
|
||||
.min(candidate.cumulative_tokens)
|
||||
.min(profile.total_input_tokens);
|
||||
matched_refresh = Some((key.clone(), entry));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((key, entry)) = matched_refresh {
|
||||
store_kiro_prompt_cache_runtime_entry(runtime_state, key.as_str(), entry).await?;
|
||||
}
|
||||
|
||||
let creation_tokens = last_breakpoint
|
||||
.cumulative_tokens
|
||||
.min(profile.total_input_tokens)
|
||||
.saturating_sub(matched_tokens);
|
||||
|
||||
for breakpoint in &profile.breakpoints {
|
||||
let key = kiro_prompt_cache_runtime_key(credential_id, &breakpoint.fingerprint);
|
||||
let ttl_secs = breakpoint.ttl.as_secs().max(1);
|
||||
let entry = existing_entries
|
||||
.get(&key)
|
||||
.copied()
|
||||
.map(|existing| KiroPromptCacheRuntimeEntry {
|
||||
token_count: existing.token_count.max(breakpoint.cumulative_tokens),
|
||||
ttl_secs: existing.ttl_secs.max(ttl_secs),
|
||||
})
|
||||
.unwrap_or(KiroPromptCacheRuntimeEntry {
|
||||
token_count: breakpoint.cumulative_tokens,
|
||||
ttl_secs,
|
||||
});
|
||||
store_kiro_prompt_cache_runtime_entry(runtime_state, key.as_str(), entry).await?;
|
||||
}
|
||||
|
||||
trim_kiro_prompt_cache_runtime_state(runtime_state, MAX_ENTRIES).await;
|
||||
|
||||
Ok(KiroPromptCacheUsage {
|
||||
cache_creation_input_tokens: creation_tokens,
|
||||
cache_read_input_tokens: matched_tokens,
|
||||
})
|
||||
}
|
||||
|
||||
async fn store_kiro_prompt_cache_runtime_entry(
|
||||
runtime_state: &RuntimeState,
|
||||
key: &str,
|
||||
entry: KiroPromptCacheRuntimeEntry,
|
||||
) -> Result<(), DataLayerError> {
|
||||
let ttl = Duration::from_secs(entry.ttl_secs.max(1));
|
||||
runtime_state
|
||||
.kv_set(
|
||||
key,
|
||||
encode_kiro_prompt_cache_runtime_entry(entry),
|
||||
Some(ttl),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let expires_at_ms = current_unix_ms().saturating_add(entry.ttl_secs.saturating_mul(1000));
|
||||
if let Err(err) = runtime_state
|
||||
.score_set(KIRO_PROMPT_CACHE_INDEX_KEY, key, expires_at_ms as f64)
|
||||
.await
|
||||
{
|
||||
warn!(
|
||||
event_name = "kiro_simulated_cache_index_update_failed",
|
||||
log_type = "event",
|
||||
cache_key = %key,
|
||||
error = ?err,
|
||||
"failed to update Kiro simulated cache index; cache entry was persisted but cleanup may lag"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn trim_kiro_prompt_cache_runtime_state(runtime_state: &RuntimeState, max_entries: usize) {
|
||||
if let Err(err) = runtime_state
|
||||
.score_remove_by_score(KIRO_PROMPT_CACHE_INDEX_KEY, current_unix_ms() as f64)
|
||||
.await
|
||||
{
|
||||
warn!(
|
||||
event_name = "kiro_simulated_cache_index_expiry_trim_failed",
|
||||
log_type = "event",
|
||||
error = ?err,
|
||||
"failed to trim expired Kiro simulated cache index entries"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let Ok(index_len) = runtime_state.score_len(KIRO_PROMPT_CACHE_INDEX_KEY).await else {
|
||||
return;
|
||||
};
|
||||
if index_len <= max_entries {
|
||||
return;
|
||||
}
|
||||
|
||||
let Ok(all_members) = runtime_state
|
||||
.score_range_by_min(KIRO_PROMPT_CACHE_INDEX_KEY, 0.0)
|
||||
.await
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let trim_count = index_len.saturating_sub(max_entries);
|
||||
if trim_count == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let trimmed_members = all_members.into_iter().take(trim_count).collect::<Vec<_>>();
|
||||
if let Err(err) = runtime_state.kv_delete_many(&trimmed_members).await {
|
||||
warn!(
|
||||
event_name = "kiro_simulated_cache_kv_trim_failed",
|
||||
log_type = "event",
|
||||
error = ?err,
|
||||
trim_count,
|
||||
"failed to delete trimmed Kiro simulated cache KV entries"
|
||||
);
|
||||
}
|
||||
if let Err(err) = runtime_state
|
||||
.score_remove_by_rank(KIRO_PROMPT_CACHE_INDEX_KEY, 0, trim_count as i64 - 1)
|
||||
.await
|
||||
{
|
||||
warn!(
|
||||
event_name = "kiro_simulated_cache_index_trim_failed",
|
||||
log_type = "event",
|
||||
error = ?err,
|
||||
trim_count,
|
||||
"failed to delete trimmed Kiro simulated cache index entries"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_kiro_prompt_cache_runtime_entry(value: &str) -> Option<KiroPromptCacheRuntimeEntry> {
|
||||
serde_json::from_str::<KiroPromptCacheRuntimeEntry>(value)
|
||||
.ok()
|
||||
.filter(|entry| entry.token_count > 0 && entry.ttl_secs > 0)
|
||||
}
|
||||
|
||||
fn encode_kiro_prompt_cache_runtime_entry(entry: KiroPromptCacheRuntimeEntry) -> String {
|
||||
serde_json::to_string(&entry).unwrap_or_else(|_| {
|
||||
format!(
|
||||
r#"{{"token_count":{},"ttl_secs":{}}}"#,
|
||||
entry.token_count, entry.ttl_secs
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn kiro_prompt_cache_runtime_key(credential_id: &str, fingerprint: &[u8; 32]) -> String {
|
||||
let credential_hash: [u8; 32] = Sha256::digest(credential_id.as_bytes()).into();
|
||||
format!(
|
||||
"kiro:prompt-cache:{}:{}",
|
||||
hex_digest(&credential_hash),
|
||||
hex_digest(fingerprint)
|
||||
)
|
||||
}
|
||||
|
||||
fn hex_digest(bytes: &[u8]) -> String {
|
||||
let mut output = String::with_capacity(bytes.len() * 2);
|
||||
for byte in bytes {
|
||||
let _ = write!(&mut output, "{byte:02x}");
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
pub(crate) fn build_kiro_prompt_cache_profile(
|
||||
request_body: &Value,
|
||||
total_input_tokens: u64,
|
||||
@@ -75,7 +312,8 @@ pub(crate) fn build_kiro_prompt_cache_profile(
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
let flattened = flatten_cacheable_blocks(request_body);
|
||||
if flattened.iter().all(|block| block.breakpoint_ttl.is_none()) {
|
||||
let automatic_ttl = extract_cache_ttl(request_body);
|
||||
if automatic_ttl.is_none() && flattened.iter().all(|block| block.breakpoint_ttl.is_none()) {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -89,12 +327,15 @@ pub(crate) fn build_kiro_prompt_cache_profile(
|
||||
prefix_hasher.update(prelude_bytes);
|
||||
|
||||
let mut cumulative_tokens = 0u64;
|
||||
let mut active_ttl: Option<Duration> = None;
|
||||
let mut breakpoints = Vec::new();
|
||||
let mut seen_fingerprints = std::collections::BTreeSet::<[u8; 32]>::new();
|
||||
let mut lookback_candidates = Vec::new();
|
||||
let mut match_candidates = Vec::new();
|
||||
let last_block_index = flattened.len().saturating_sub(1);
|
||||
|
||||
for block in flattened {
|
||||
for (block_index, mut block) in flattened.into_iter().enumerate() {
|
||||
if block.breakpoint_ttl.is_none() && block_index == last_block_index {
|
||||
block.breakpoint_ttl = automatic_ttl;
|
||||
}
|
||||
cumulative_tokens = cumulative_tokens.saturating_add(block.tokens);
|
||||
let block_bytes = serde_json::to_vec(&block.value).unwrap_or_default();
|
||||
let block_hash: [u8; 32] = Sha256::digest(block_bytes).into();
|
||||
@@ -105,13 +346,6 @@ pub(crate) fn build_kiro_prompt_cache_profile(
|
||||
prefix_hasher.update(fingerprint);
|
||||
|
||||
if let Some(ttl) = block.breakpoint_ttl {
|
||||
push_lookback_breakpoints(
|
||||
&mut breakpoints,
|
||||
&mut seen_fingerprints,
|
||||
&lookback_candidates,
|
||||
ttl,
|
||||
);
|
||||
active_ttl = Some(ttl);
|
||||
push_breakpoint(
|
||||
&mut breakpoints,
|
||||
&mut seen_fingerprints,
|
||||
@@ -120,18 +354,7 @@ pub(crate) fn build_kiro_prompt_cache_profile(
|
||||
ttl,
|
||||
);
|
||||
}
|
||||
if block.is_message_end {
|
||||
if let Some(ttl) = active_ttl {
|
||||
push_breakpoint(
|
||||
&mut breakpoints,
|
||||
&mut seen_fingerprints,
|
||||
fingerprint,
|
||||
cumulative_tokens,
|
||||
ttl,
|
||||
);
|
||||
}
|
||||
}
|
||||
push_prefix_candidate(&mut lookback_candidates, fingerprint, cumulative_tokens);
|
||||
push_match_candidate(&mut match_candidates, fingerprint, cumulative_tokens);
|
||||
}
|
||||
|
||||
let min_cacheable_tokens = minimum_cacheable_tokens_for_model(model);
|
||||
@@ -139,13 +362,54 @@ pub(crate) fn build_kiro_prompt_cache_profile(
|
||||
.into_iter()
|
||||
.filter(|breakpoint| breakpoint.cumulative_tokens >= min_cacheable_tokens)
|
||||
.collect::<Vec<_>>();
|
||||
(!cacheable_breakpoints.is_empty()).then_some(KiroPromptCacheProfile {
|
||||
if cacheable_breakpoints.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let match_candidates = build_lookback_match_candidates(
|
||||
&match_candidates,
|
||||
&cacheable_breakpoints,
|
||||
min_cacheable_tokens,
|
||||
);
|
||||
Some(KiroPromptCacheProfile {
|
||||
total_input_tokens,
|
||||
min_cacheable_tokens,
|
||||
breakpoints: cacheable_breakpoints,
|
||||
match_candidates,
|
||||
})
|
||||
}
|
||||
|
||||
fn build_lookback_match_candidates(
|
||||
candidates: &[KiroPromptCacheCandidate],
|
||||
breakpoints: &[KiroPromptCacheBreakpoint],
|
||||
min_cacheable_tokens: u64,
|
||||
) -> Vec<KiroPromptCacheCandidate> {
|
||||
let mut out = Vec::new();
|
||||
let mut seen_fingerprints = std::collections::BTreeSet::<[u8; 32]>::new();
|
||||
|
||||
for breakpoint in breakpoints {
|
||||
let Some(index) = candidates
|
||||
.iter()
|
||||
.position(|candidate| candidate.fingerprint == breakpoint.fingerprint)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let start = index
|
||||
.saturating_add(1)
|
||||
.saturating_sub(PREFIX_LOOKBACK_WINDOW);
|
||||
for candidate in &candidates[start..=index] {
|
||||
if candidate.cumulative_tokens < min_cacheable_tokens
|
||||
|| candidate.cumulative_tokens > breakpoint.cumulative_tokens
|
||||
|| !seen_fingerprints.insert(candidate.fingerprint)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
out.push(*candidate);
|
||||
}
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
pub(crate) fn kiro_simulated_cache_enabled_from_provider_config(config: Option<&Value>) -> bool {
|
||||
config
|
||||
.and_then(Value::as_object)
|
||||
@@ -269,35 +533,15 @@ fn push_breakpoint(
|
||||
}
|
||||
}
|
||||
|
||||
fn push_lookback_breakpoints(
|
||||
breakpoints: &mut Vec<KiroPromptCacheBreakpoint>,
|
||||
seen_fingerprints: &mut std::collections::BTreeSet<[u8; 32]>,
|
||||
candidates: &[PrefixCandidate],
|
||||
ttl: Duration,
|
||||
) {
|
||||
for candidate in candidates {
|
||||
push_breakpoint(
|
||||
breakpoints,
|
||||
seen_fingerprints,
|
||||
candidate.fingerprint,
|
||||
candidate.cumulative_tokens,
|
||||
ttl,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn push_prefix_candidate(
|
||||
candidates: &mut Vec<PrefixCandidate>,
|
||||
fn push_match_candidate(
|
||||
candidates: &mut Vec<KiroPromptCacheCandidate>,
|
||||
fingerprint: [u8; 32],
|
||||
cumulative_tokens: u64,
|
||||
) {
|
||||
candidates.push(PrefixCandidate {
|
||||
candidates.push(KiroPromptCacheCandidate {
|
||||
fingerprint,
|
||||
cumulative_tokens,
|
||||
});
|
||||
if candidates.len() > PREFIX_LOOKBACK_LIMIT {
|
||||
candidates.remove(0);
|
||||
}
|
||||
}
|
||||
|
||||
fn flatten_cacheable_blocks(request_body: &Value) -> Vec<PendingBlock> {
|
||||
@@ -316,7 +560,6 @@ fn flatten_cacheable_blocks(request_body: &Value) -> Vec<PendingBlock> {
|
||||
tokens: TOKENS_PER_TOOL,
|
||||
value,
|
||||
breakpoint_ttl,
|
||||
is_message_end: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -337,7 +580,6 @@ fn flatten_cacheable_blocks(request_body: &Value) -> Vec<PendingBlock> {
|
||||
tokens: count_system_block_tokens(item),
|
||||
value,
|
||||
breakpoint_ttl,
|
||||
is_message_end: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -351,7 +593,6 @@ fn flatten_cacheable_blocks(request_body: &Value) -> Vec<PendingBlock> {
|
||||
tokens: count_text_tokens(text),
|
||||
value,
|
||||
breakpoint_ttl: None,
|
||||
is_message_end: false,
|
||||
});
|
||||
}
|
||||
other => {
|
||||
@@ -364,7 +605,6 @@ fn flatten_cacheable_blocks(request_body: &Value) -> Vec<PendingBlock> {
|
||||
tokens: count_system_block_tokens(other),
|
||||
value,
|
||||
breakpoint_ttl: None,
|
||||
is_message_end: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -376,11 +616,17 @@ fn flatten_cacheable_blocks(request_body: &Value) -> Vec<PendingBlock> {
|
||||
.get("role")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
let message_breakpoint_ttl = extract_cache_ttl(message);
|
||||
match message.get("content") {
|
||||
Some(Value::Array(items)) => {
|
||||
let last_block_index = items.len().saturating_sub(1);
|
||||
for (block_index, item) in items.iter().enumerate() {
|
||||
let breakpoint_ttl = extract_cache_ttl(item);
|
||||
let breakpoint_ttl =
|
||||
extract_cache_ttl(item).or(if block_index == last_block_index {
|
||||
message_breakpoint_ttl
|
||||
} else {
|
||||
None
|
||||
});
|
||||
let mut normalized = item.clone();
|
||||
strip_cache_control(&mut normalized);
|
||||
let value = canonicalize_json(serde_json::json!({
|
||||
@@ -394,7 +640,6 @@ fn flatten_cacheable_blocks(request_body: &Value) -> Vec<PendingBlock> {
|
||||
tokens: count_message_content_tokens(item),
|
||||
value,
|
||||
breakpoint_ttl,
|
||||
is_message_end: block_index == last_block_index,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -409,8 +654,7 @@ fn flatten_cacheable_blocks(request_body: &Value) -> Vec<PendingBlock> {
|
||||
blocks.push(PendingBlock {
|
||||
tokens: count_text_tokens(text),
|
||||
value,
|
||||
breakpoint_ttl: None,
|
||||
is_message_end: true,
|
||||
breakpoint_ttl: message_breakpoint_ttl,
|
||||
});
|
||||
}
|
||||
Some(other) => {
|
||||
@@ -424,8 +668,7 @@ fn flatten_cacheable_blocks(request_body: &Value) -> Vec<PendingBlock> {
|
||||
blocks.push(PendingBlock {
|
||||
tokens: count_message_content_tokens(other),
|
||||
value,
|
||||
breakpoint_ttl: None,
|
||||
is_message_end: true,
|
||||
breakpoint_ttl: message_breakpoint_ttl,
|
||||
});
|
||||
}
|
||||
None => {}
|
||||
@@ -634,20 +877,16 @@ impl KiroPromptCacheTracker {
|
||||
};
|
||||
|
||||
let mut matched_tokens = 0;
|
||||
for breakpoint in profile
|
||||
.breakpoints
|
||||
.iter()
|
||||
.rev()
|
||||
.take(PREFIX_LOOKBACK_LIMIT.saturating_add(1))
|
||||
{
|
||||
let key = (credential_id.clone(), breakpoint.fingerprint);
|
||||
let Some(entry) = entries.get(&key) else {
|
||||
for candidate in profile.match_candidates.iter().rev() {
|
||||
let key = (credential_id.clone(), candidate.fingerprint);
|
||||
let Some(entry) = entries.get_mut(&key) else {
|
||||
continue;
|
||||
};
|
||||
if entry.expires_at > now {
|
||||
entry.expires_at = entry.expires_at.max(now + entry.ttl);
|
||||
matched_tokens = entry
|
||||
.token_count
|
||||
.min(breakpoint.cumulative_tokens)
|
||||
.min(candidate.cumulative_tokens)
|
||||
.min(profile.total_input_tokens);
|
||||
break;
|
||||
}
|
||||
@@ -664,6 +903,7 @@ impl KiroPromptCacheTracker {
|
||||
Some(existing) => {
|
||||
existing.token_count = existing.token_count.max(breakpoint.cumulative_tokens);
|
||||
existing.ttl = existing.ttl.max(breakpoint.ttl);
|
||||
existing.expires_at = existing.expires_at.max(now + existing.ttl);
|
||||
}
|
||||
None => {
|
||||
self.evict_to_capacity(&mut entries);
|
||||
@@ -702,6 +942,7 @@ impl KiroPromptCacheTracker {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use aether_runtime_state::MemoryRuntimeStateConfig;
|
||||
|
||||
fn long_text(label: &str) -> String {
|
||||
format!("{} {}", label, "cacheable prompt chunk ".repeat(300))
|
||||
@@ -756,7 +997,138 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracker_supports_prefix_hits_without_extending_expiry() {
|
||||
fn profile_reads_top_level_automatic_cache_control() {
|
||||
let request = serde_json::json!({
|
||||
"model": "claude-sonnet-4.6",
|
||||
"cache_control": {"type": "ephemeral"},
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": long_text("automatic cached turn")
|
||||
}]
|
||||
});
|
||||
|
||||
let profile =
|
||||
build_kiro_prompt_cache_profile(&request, estimate_kiro_prompt_input_tokens(&request))
|
||||
.expect("top-level cache_control should create an automatic cache profile");
|
||||
let tracker = KiroPromptCacheTracker::default();
|
||||
let usage = tracker.compute_and_update("cred".to_string(), &profile);
|
||||
|
||||
assert_eq!(profile.breakpoints.len(), 1);
|
||||
assert!(usage.cache_creation_input_tokens > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_does_not_create_message_end_breakpoints_from_explicit_cache_control() {
|
||||
let request = serde_json::json!({
|
||||
"model": "claude-sonnet-4.6",
|
||||
"system": [{
|
||||
"type": "text",
|
||||
"text": long_text("explicit cached system"),
|
||||
"cache_control": {"type": "ephemeral"}
|
||||
}],
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": long_text("uncached later turn")
|
||||
}]
|
||||
});
|
||||
|
||||
let profile =
|
||||
build_kiro_prompt_cache_profile(&request, estimate_kiro_prompt_input_tokens(&request))
|
||||
.expect("explicit cache_control should create a cache profile");
|
||||
|
||||
assert_eq!(profile.breakpoints.len(), 1);
|
||||
assert!(profile.breakpoints[0].cumulative_tokens < profile.total_input_tokens);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_state_tracker_reads_cached_prefix_across_calls() {
|
||||
let request = serde_json::json!({
|
||||
"model": "claude-sonnet-4.6",
|
||||
"system": [{
|
||||
"type": "text",
|
||||
"text": long_text("runtime shared system"),
|
||||
"cache_control": {"type": "ephemeral"}
|
||||
}],
|
||||
"messages": [{"role": "user", "content": "reuse runtime cache"}]
|
||||
});
|
||||
let profile =
|
||||
build_kiro_prompt_cache_profile(&request, estimate_kiro_prompt_input_tokens(&request))
|
||||
.expect("cacheable request should create a cache profile");
|
||||
let runtime = RuntimeState::memory(MemoryRuntimeStateConfig::default());
|
||||
|
||||
let first =
|
||||
compute_kiro_prompt_cache_usage(&runtime, "runtime-cred".to_string(), &profile).await;
|
||||
let second =
|
||||
compute_kiro_prompt_cache_usage(&runtime, "runtime-cred".to_string(), &profile).await;
|
||||
|
||||
assert!(first.cache_creation_input_tokens > 0);
|
||||
assert_eq!(first.cache_read_input_tokens, 0);
|
||||
assert_eq!(second.cache_creation_input_tokens, 0);
|
||||
assert!(second.cache_read_input_tokens > 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_state_tracker_trims_oldest_entries_to_capacity() {
|
||||
let runtime = RuntimeState::memory(MemoryRuntimeStateConfig::default());
|
||||
let now_ms = current_unix_ms();
|
||||
let keys = [
|
||||
"kiro:prompt-cache:test-oldest".to_string(),
|
||||
"kiro:prompt-cache:test-middle".to_string(),
|
||||
"kiro:prompt-cache:test-newest".to_string(),
|
||||
];
|
||||
|
||||
for (index, key) in keys.iter().enumerate() {
|
||||
runtime
|
||||
.kv_set(
|
||||
key,
|
||||
encode_kiro_prompt_cache_runtime_entry(KiroPromptCacheRuntimeEntry {
|
||||
token_count: 100 + index as u64,
|
||||
ttl_secs: 120,
|
||||
}),
|
||||
Some(Duration::from_secs(120)),
|
||||
)
|
||||
.await
|
||||
.expect("cache entry should store");
|
||||
runtime
|
||||
.score_set(
|
||||
KIRO_PROMPT_CACHE_INDEX_KEY,
|
||||
key,
|
||||
now_ms.saturating_add(60_000 + index as u64 * 1_000) as f64,
|
||||
)
|
||||
.await
|
||||
.expect("cache index should store");
|
||||
}
|
||||
|
||||
trim_kiro_prompt_cache_runtime_state(&runtime, 2).await;
|
||||
|
||||
assert_eq!(
|
||||
runtime
|
||||
.kv_get(&keys[0])
|
||||
.await
|
||||
.expect("oldest entry should read"),
|
||||
None
|
||||
);
|
||||
assert!(runtime
|
||||
.kv_get(&keys[1])
|
||||
.await
|
||||
.expect("middle entry should read")
|
||||
.is_some());
|
||||
assert!(runtime
|
||||
.kv_get(&keys[2])
|
||||
.await
|
||||
.expect("newest entry should read")
|
||||
.is_some());
|
||||
assert_eq!(
|
||||
runtime
|
||||
.score_range_by_min(KIRO_PROMPT_CACHE_INDEX_KEY, 0.0)
|
||||
.await
|
||||
.expect("cache index should read"),
|
||||
vec![keys[1].clone(), keys[2].clone()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracker_refreshes_cached_prefix_ttl_on_read() {
|
||||
let base = serde_json::json!({
|
||||
"model": "claude-sonnet-4.6",
|
||||
"system": [{
|
||||
@@ -799,13 +1171,13 @@ mod tests {
|
||||
);
|
||||
assert!(hit.cache_read_input_tokens > 0);
|
||||
|
||||
let expired = tracker.compute_and_update_at(
|
||||
let refreshed = tracker.compute_and_update_at(
|
||||
"cred".to_string(),
|
||||
&base_profile,
|
||||
start + Duration::from_secs(301),
|
||||
);
|
||||
assert!(expired.cache_creation_input_tokens > 0);
|
||||
assert_eq!(expired.cache_read_input_tokens, 0);
|
||||
assert_eq!(refreshed.cache_creation_input_tokens, 0);
|
||||
assert!(refreshed.cache_read_input_tokens > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -867,6 +1239,146 @@ mod tests {
|
||||
assert!(hit.cache_creation_input_tokens > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracker_reads_cached_prefix_within_prompt_cache_lookback_window() {
|
||||
let first = serde_json::json!({
|
||||
"model": "claude-sonnet-4.6",
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": long_text("shared first turn"),
|
||||
"cache_control": {"type": "ephemeral"}
|
||||
}]
|
||||
}]
|
||||
});
|
||||
let mut second_messages = vec![serde_json::json!({
|
||||
"role": "user",
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": long_text("shared first turn")
|
||||
}]
|
||||
})];
|
||||
for index in 0..12 {
|
||||
second_messages.push(serde_json::json!({
|
||||
"role": if index % 2 == 0 { "assistant" } else { "user" },
|
||||
"content": format!("intermediate turn {index}")
|
||||
}));
|
||||
}
|
||||
second_messages.push(serde_json::json!({
|
||||
"role": "user",
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": long_text("new tail turn"),
|
||||
"cache_control": {"type": "ephemeral"}
|
||||
}]
|
||||
}));
|
||||
let second = serde_json::json!({
|
||||
"model": "claude-sonnet-4.6",
|
||||
"messages": second_messages
|
||||
});
|
||||
let first_profile =
|
||||
build_kiro_prompt_cache_profile(&first, estimate_kiro_prompt_input_tokens(&first))
|
||||
.expect("first request should be cacheable");
|
||||
let second_profile =
|
||||
build_kiro_prompt_cache_profile(&second, estimate_kiro_prompt_input_tokens(&second))
|
||||
.expect("second request should be cacheable");
|
||||
let tracker = KiroPromptCacheTracker::default();
|
||||
let start = Instant::now();
|
||||
|
||||
let created = tracker.compute_and_update_at("cred".to_string(), &first_profile, start);
|
||||
assert!(created.cache_creation_input_tokens > 0);
|
||||
assert_eq!(created.cache_read_input_tokens, 0);
|
||||
|
||||
let hit = tracker.compute_and_update_at(
|
||||
"cred".to_string(),
|
||||
&second_profile,
|
||||
start + Duration::from_secs(60),
|
||||
);
|
||||
assert!(hit.cache_read_input_tokens > 0);
|
||||
assert!(hit.cache_creation_input_tokens > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracker_does_not_read_cached_prefix_outside_prompt_cache_lookback_window() {
|
||||
let first = serde_json::json!({
|
||||
"model": "claude-sonnet-4.6",
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": long_text("shared first turn"),
|
||||
"cache_control": {"type": "ephemeral"}
|
||||
}]
|
||||
}]
|
||||
});
|
||||
let mut second_messages = vec![serde_json::json!({
|
||||
"role": "user",
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": long_text("shared first turn")
|
||||
}]
|
||||
})];
|
||||
for index in 0..20 {
|
||||
second_messages.push(serde_json::json!({
|
||||
"role": if index % 2 == 0 { "assistant" } else { "user" },
|
||||
"content": format!("intermediate turn {index}")
|
||||
}));
|
||||
}
|
||||
second_messages.push(serde_json::json!({
|
||||
"role": "user",
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": long_text("new tail turn"),
|
||||
"cache_control": {"type": "ephemeral"}
|
||||
}]
|
||||
}));
|
||||
let second = serde_json::json!({
|
||||
"model": "claude-sonnet-4.6",
|
||||
"messages": second_messages
|
||||
});
|
||||
let first_profile =
|
||||
build_kiro_prompt_cache_profile(&first, estimate_kiro_prompt_input_tokens(&first))
|
||||
.expect("first request should be cacheable");
|
||||
let second_profile =
|
||||
build_kiro_prompt_cache_profile(&second, estimate_kiro_prompt_input_tokens(&second))
|
||||
.expect("second request should be cacheable");
|
||||
let tracker = KiroPromptCacheTracker::default();
|
||||
let start = Instant::now();
|
||||
|
||||
let created = tracker.compute_and_update_at("cred".to_string(), &first_profile, start);
|
||||
assert!(created.cache_creation_input_tokens > 0);
|
||||
assert_eq!(created.cache_read_input_tokens, 0);
|
||||
|
||||
let miss = tracker.compute_and_update_at(
|
||||
"cred".to_string(),
|
||||
&second_profile,
|
||||
start + Duration::from_secs(60),
|
||||
);
|
||||
assert!(miss.cache_creation_input_tokens > 0);
|
||||
assert_eq!(miss.cache_read_input_tokens, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_reads_message_level_cache_control() {
|
||||
let request = serde_json::json!({
|
||||
"model": "claude-sonnet-4.6",
|
||||
"messages": [{
|
||||
"role": "system",
|
||||
"content": long_text("message level cached system"),
|
||||
"cache_control": {"type": "ephemeral"}
|
||||
}]
|
||||
});
|
||||
|
||||
let profile =
|
||||
build_kiro_prompt_cache_profile(&request, estimate_kiro_prompt_input_tokens(&request))
|
||||
.expect("message-level cache_control should create a cache profile");
|
||||
let tracker = KiroPromptCacheTracker::default();
|
||||
let usage = tracker.compute_and_update("cred".to_string(), &profile);
|
||||
|
||||
assert!(usage.cache_creation_input_tokens > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn billed_input_tokens_subtracts_cache_usage() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -15,8 +15,8 @@ use tracing::{debug, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::execution_runtime::kiro_cache::{
|
||||
billed_input_tokens, build_kiro_prompt_cache_profile, estimate_kiro_prompt_input_tokens,
|
||||
kiro_prompt_cache_tracker, kiro_simulated_cache_enabled_from_provider_config,
|
||||
billed_input_tokens, build_kiro_prompt_cache_profile, compute_kiro_prompt_cache_usage,
|
||||
estimate_kiro_prompt_input_tokens, kiro_simulated_cache_enabled_from_provider_config,
|
||||
KiroPromptCacheProfile, KiroPromptCacheUsage,
|
||||
};
|
||||
use crate::execution_runtime::ndjson::encode_stream_frame_ndjson;
|
||||
@@ -160,14 +160,17 @@ pub(crate) async fn maybe_execute_kiro_web_search_stream(
|
||||
|
||||
let search_results = parse_mcp_search_results(&mcp_execution.result);
|
||||
let cache_usage = if kiro_simulated_cache_enabled(state, plan).await {
|
||||
request
|
||||
.cache_profile
|
||||
.as_ref()
|
||||
.map(|profile| {
|
||||
kiro_prompt_cache_tracker()
|
||||
.compute_and_update(kiro_cache_credential_id(plan), profile)
|
||||
})
|
||||
.unwrap_or_default()
|
||||
match request.cache_profile.as_ref() {
|
||||
Some(profile) => {
|
||||
compute_kiro_prompt_cache_usage(
|
||||
state.runtime_state(),
|
||||
kiro_cache_credential_id(plan),
|
||||
profile,
|
||||
)
|
||||
.await
|
||||
}
|
||||
None => KiroPromptCacheUsage::default(),
|
||||
}
|
||||
} else {
|
||||
KiroPromptCacheUsage::default()
|
||||
};
|
||||
|
||||
@@ -65,7 +65,7 @@ use crate::execution_runtime::chatgpt_web_image::maybe_execute_chatgpt_web_image
|
||||
use crate::execution_runtime::grok::maybe_execute_grok_stream;
|
||||
use crate::execution_runtime::kiro_cache::{
|
||||
billed_input_tokens as kiro_billed_input_tokens, build_kiro_prompt_cache_profile,
|
||||
estimate_kiro_prompt_input_tokens, kiro_prompt_cache_tracker,
|
||||
compute_kiro_prompt_cache_usage, estimate_kiro_prompt_input_tokens,
|
||||
kiro_simulated_cache_enabled_from_provider_config,
|
||||
kiro_simulated_cache_enabled_from_report_context, KiroPromptCacheUsage,
|
||||
KIRO_SIMULATED_CACHE_ENABLED_CONTEXT_FIELD,
|
||||
@@ -402,7 +402,8 @@ async fn seed_kiro_simulated_cache_enabled(
|
||||
}
|
||||
}
|
||||
|
||||
fn seed_kiro_report_context_prompt_cache_usage(
|
||||
async fn seed_kiro_report_context_prompt_cache_usage(
|
||||
state: &AppState,
|
||||
plan: &ExecutionPlan,
|
||||
report_context: &mut Option<Value>,
|
||||
) {
|
||||
@@ -450,8 +451,12 @@ fn seed_kiro_report_context_prompt_cache_usage(
|
||||
return;
|
||||
};
|
||||
|
||||
let cache_usage = kiro_prompt_cache_tracker()
|
||||
.compute_and_update(kiro_stream_cache_credential_id(plan), &profile);
|
||||
let cache_usage = compute_kiro_prompt_cache_usage(
|
||||
state.runtime_state(),
|
||||
kiro_stream_cache_credential_id(plan),
|
||||
&profile,
|
||||
)
|
||||
.await;
|
||||
if cache_usage.cache_creation_input_tokens == 0 && cache_usage.cache_read_input_tokens == 0 {
|
||||
return;
|
||||
}
|
||||
@@ -494,7 +499,8 @@ fn kiro_cache_usage_from_report_context(report_context: &Value) -> Option<KiroPr
|
||||
.and_then(kiro_cache_usage_from_context_object)
|
||||
}
|
||||
|
||||
fn maybe_apply_kiro_prompt_cache_usage_to_stream_summary(
|
||||
async fn maybe_apply_kiro_prompt_cache_usage_to_stream_summary(
|
||||
state: &AppState,
|
||||
plan: &ExecutionPlan,
|
||||
report_context: Option<&Value>,
|
||||
summary: &mut Option<ExecutionStreamTerminalSummary>,
|
||||
@@ -572,8 +578,12 @@ fn maybe_apply_kiro_prompt_cache_usage_to_stream_summary(
|
||||
return;
|
||||
};
|
||||
|
||||
let cache_usage = kiro_prompt_cache_tracker()
|
||||
.compute_and_update(kiro_stream_cache_credential_id(plan), &profile);
|
||||
let cache_usage = compute_kiro_prompt_cache_usage(
|
||||
state.runtime_state(),
|
||||
kiro_stream_cache_credential_id(plan),
|
||||
&profile,
|
||||
)
|
||||
.await;
|
||||
if cache_usage.cache_creation_input_tokens == 0 && cache_usage.cache_read_input_tokens == 0 {
|
||||
return;
|
||||
}
|
||||
@@ -1984,7 +1994,7 @@ async fn execute_stream_from_frame_stream(
|
||||
seed_kiro_report_context_input_tokens(&plan, &mut report_context);
|
||||
if status_code == 200 {
|
||||
seed_kiro_simulated_cache_enabled(state, &plan, &mut report_context).await;
|
||||
seed_kiro_report_context_prompt_cache_usage(&plan, &mut report_context);
|
||||
seed_kiro_report_context_prompt_cache_usage(state, &plan, &mut report_context).await;
|
||||
}
|
||||
let mut buffered_frames = VecDeque::new();
|
||||
let mut stream_terminal_summary: Option<ExecutionStreamTerminalSummary> = None;
|
||||
@@ -3705,10 +3715,12 @@ async fn execute_stream_from_frame_stream(
|
||||
}
|
||||
|
||||
maybe_apply_kiro_prompt_cache_usage_to_stream_summary(
|
||||
&state_for_report,
|
||||
&plan_for_report,
|
||||
report_context_owned.as_ref(),
|
||||
&mut stream_terminal_summary,
|
||||
);
|
||||
)
|
||||
.await;
|
||||
let requires_observed_terminal_event = stream_requires_observed_terminal_event(
|
||||
plan_for_report.provider_api_format.as_str(),
|
||||
stream_usage_report_context.as_ref(),
|
||||
@@ -3952,6 +3964,10 @@ mod tests {
|
||||
.with_execution_runtime_candidate(true)
|
||||
}
|
||||
|
||||
fn test_state() -> AppState {
|
||||
AppState::new().expect("gateway state should build")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_client_visible_sse_terminal_events() {
|
||||
assert!(stream_chunk_contains_sse_done(b"data: [DONE]\n\n"));
|
||||
@@ -4136,8 +4152,8 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kiro_stream_summary_applies_prompt_cache_usage_from_original_request() {
|
||||
#[tokio::test]
|
||||
async fn kiro_stream_summary_applies_prompt_cache_usage_from_original_request() {
|
||||
let request_body = json!({
|
||||
"model": "claude-opus-4-7",
|
||||
"system": [
|
||||
@@ -4185,6 +4201,7 @@ mod tests {
|
||||
transport_profile: None,
|
||||
timeouts: None,
|
||||
};
|
||||
let state = test_state();
|
||||
|
||||
let mut first_summary = Some(ExecutionStreamTerminalSummary {
|
||||
standardized_usage: Some(StandardizedUsage {
|
||||
@@ -4195,10 +4212,12 @@ mod tests {
|
||||
..ExecutionStreamTerminalSummary::default()
|
||||
});
|
||||
maybe_apply_kiro_prompt_cache_usage_to_stream_summary(
|
||||
&state,
|
||||
&plan,
|
||||
Some(&report_context),
|
||||
&mut first_summary,
|
||||
);
|
||||
)
|
||||
.await;
|
||||
let first_usage = first_summary
|
||||
.as_ref()
|
||||
.and_then(|summary| summary.standardized_usage.as_ref())
|
||||
@@ -4215,10 +4234,12 @@ mod tests {
|
||||
..ExecutionStreamTerminalSummary::default()
|
||||
});
|
||||
maybe_apply_kiro_prompt_cache_usage_to_stream_summary(
|
||||
&state,
|
||||
&plan,
|
||||
Some(&report_context),
|
||||
&mut second_summary,
|
||||
);
|
||||
)
|
||||
.await;
|
||||
let second_usage = second_summary
|
||||
.as_ref()
|
||||
.and_then(|summary| summary.standardized_usage.as_ref())
|
||||
@@ -4229,8 +4250,126 @@ mod tests {
|
||||
assert_eq!(second_usage.output_tokens, 19);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kiro_stream_summary_seeds_input_tokens_without_cache_control() {
|
||||
#[tokio::test]
|
||||
async fn kiro_stream_summary_reads_cached_prefix_within_prompt_cache_lookback_window() {
|
||||
let first_request_body = json!({
|
||||
"model": "claude-sonnet-4.6",
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": "shared first turn ".repeat(600),
|
||||
"cache_control": {"type": "ephemeral"}
|
||||
}]
|
||||
}]
|
||||
});
|
||||
let mut second_messages = vec![json!({
|
||||
"role": "user",
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": "shared first turn ".repeat(600)
|
||||
}]
|
||||
})];
|
||||
for index in 0..12 {
|
||||
second_messages.push(json!({
|
||||
"role": if index % 2 == 0 { "assistant" } else { "user" },
|
||||
"content": format!("intermediate stream turn {index}")
|
||||
}));
|
||||
}
|
||||
second_messages.push(json!({
|
||||
"role": "user",
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": "new tail turn ".repeat(600),
|
||||
"cache_control": {"type": "ephemeral"}
|
||||
}]
|
||||
}));
|
||||
let second_request_body = json!({
|
||||
"model": "claude-sonnet-4.6",
|
||||
"messages": second_messages
|
||||
});
|
||||
let plan = ExecutionPlan {
|
||||
request_id: "req-kiro-cache-stream-long-tail".into(),
|
||||
candidate_id: Some("cand-kiro-cache-stream-long-tail".into()),
|
||||
provider_name: Some("Kiro".into()),
|
||||
provider_id: "provider-kiro-cache-stream-long-tail".into(),
|
||||
endpoint_id: "endpoint-kiro-cache-stream-long-tail".into(),
|
||||
key_id: "key-kiro-cache-stream-long-tail".into(),
|
||||
method: "POST".into(),
|
||||
url: "https://q.us-east-1.amazonaws.com/generateAssistantResponse?beta=true".into(),
|
||||
headers: BTreeMap::new(),
|
||||
content_type: Some("application/json".into()),
|
||||
content_encoding: None,
|
||||
body: RequestBody::from_json(json!({"conversationState": {}})),
|
||||
stream: true,
|
||||
client_api_format: "claude:messages".into(),
|
||||
provider_api_format: "claude:messages".into(),
|
||||
model_name: Some("claude-sonnet-4.6".into()),
|
||||
proxy: None,
|
||||
transport_profile: None,
|
||||
timeouts: None,
|
||||
};
|
||||
let first_report_context = json!({
|
||||
"original_request_body": first_request_body,
|
||||
"kiro_simulated_cache_enabled": true,
|
||||
});
|
||||
let second_report_context = json!({
|
||||
"original_request_body": second_request_body,
|
||||
"kiro_simulated_cache_enabled": true,
|
||||
});
|
||||
let state = test_state();
|
||||
|
||||
let mut first_summary = Some(ExecutionStreamTerminalSummary {
|
||||
standardized_usage: Some(StandardizedUsage {
|
||||
input_tokens: 4_000,
|
||||
output_tokens: 17,
|
||||
..StandardizedUsage::new()
|
||||
}),
|
||||
..ExecutionStreamTerminalSummary::default()
|
||||
});
|
||||
maybe_apply_kiro_prompt_cache_usage_to_stream_summary(
|
||||
&state,
|
||||
&plan,
|
||||
Some(&first_report_context),
|
||||
&mut first_summary,
|
||||
)
|
||||
.await;
|
||||
let first_usage = first_summary
|
||||
.as_ref()
|
||||
.and_then(|summary| summary.standardized_usage.as_ref())
|
||||
.expect("first usage should exist");
|
||||
assert!(first_usage.cache_creation_tokens > 0);
|
||||
assert_eq!(first_usage.cache_read_tokens, 0);
|
||||
|
||||
let mut second_summary = Some(ExecutionStreamTerminalSummary {
|
||||
standardized_usage: Some(StandardizedUsage {
|
||||
input_tokens: 8_000,
|
||||
output_tokens: 19,
|
||||
..StandardizedUsage::new()
|
||||
}),
|
||||
..ExecutionStreamTerminalSummary::default()
|
||||
});
|
||||
maybe_apply_kiro_prompt_cache_usage_to_stream_summary(
|
||||
&state,
|
||||
&plan,
|
||||
Some(&second_report_context),
|
||||
&mut second_summary,
|
||||
)
|
||||
.await;
|
||||
let second_usage = second_summary
|
||||
.as_ref()
|
||||
.and_then(|summary| summary.standardized_usage.as_ref())
|
||||
.expect("second usage should exist");
|
||||
assert!(
|
||||
second_usage.cache_read_tokens > 0,
|
||||
"stream summary should reuse the far earlier cached prefix"
|
||||
);
|
||||
assert!(second_usage.cache_creation_tokens > 0);
|
||||
assert_eq!(second_usage.output_tokens, 19);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn kiro_stream_summary_seeds_input_tokens_without_cache_control() {
|
||||
let request_body = json!({
|
||||
"model": "claude-opus-4-7",
|
||||
"system": [
|
||||
@@ -4276,6 +4415,7 @@ mod tests {
|
||||
transport_profile: None,
|
||||
timeouts: None,
|
||||
};
|
||||
let state = test_state();
|
||||
|
||||
let mut summary = Some(ExecutionStreamTerminalSummary {
|
||||
standardized_usage: Some(StandardizedUsage {
|
||||
@@ -4287,10 +4427,12 @@ mod tests {
|
||||
});
|
||||
|
||||
maybe_apply_kiro_prompt_cache_usage_to_stream_summary(
|
||||
&state,
|
||||
&plan,
|
||||
Some(&report_context),
|
||||
&mut summary,
|
||||
);
|
||||
)
|
||||
.await;
|
||||
|
||||
let usage = summary
|
||||
.as_ref()
|
||||
@@ -4303,8 +4445,8 @@ mod tests {
|
||||
assert_eq!(usage.output_tokens, 13);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kiro_stream_summary_bills_existing_cache_usage_when_input_is_zero() {
|
||||
#[tokio::test]
|
||||
async fn kiro_stream_summary_bills_existing_cache_usage_when_input_is_zero() {
|
||||
let request_body = json!({
|
||||
"model": "claude-opus-4-7",
|
||||
"system": [
|
||||
@@ -4350,6 +4492,7 @@ mod tests {
|
||||
transport_profile: None,
|
||||
timeouts: None,
|
||||
};
|
||||
let state = test_state();
|
||||
|
||||
let mut summary = Some(ExecutionStreamTerminalSummary {
|
||||
standardized_usage: Some(StandardizedUsage {
|
||||
@@ -4362,10 +4505,12 @@ mod tests {
|
||||
});
|
||||
|
||||
maybe_apply_kiro_prompt_cache_usage_to_stream_summary(
|
||||
&state,
|
||||
&plan,
|
||||
Some(&report_context),
|
||||
&mut summary,
|
||||
);
|
||||
)
|
||||
.await;
|
||||
|
||||
let usage = summary
|
||||
.as_ref()
|
||||
@@ -4378,8 +4523,8 @@ mod tests {
|
||||
assert_eq!(usage.output_tokens, 23);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kiro_stream_summary_clears_cache_usage_when_simulated_cache_disabled() {
|
||||
#[tokio::test]
|
||||
async fn kiro_stream_summary_clears_cache_usage_when_simulated_cache_disabled() {
|
||||
let request_body = json!({
|
||||
"model": "claude-opus-4-7",
|
||||
"system": [
|
||||
@@ -4426,6 +4571,7 @@ mod tests {
|
||||
transport_profile: None,
|
||||
timeouts: None,
|
||||
};
|
||||
let state = test_state();
|
||||
|
||||
let mut summary = Some(ExecutionStreamTerminalSummary {
|
||||
standardized_usage: Some(StandardizedUsage {
|
||||
@@ -4439,10 +4585,12 @@ mod tests {
|
||||
});
|
||||
|
||||
maybe_apply_kiro_prompt_cache_usage_to_stream_summary(
|
||||
&state,
|
||||
&plan,
|
||||
Some(&report_context),
|
||||
&mut summary,
|
||||
);
|
||||
)
|
||||
.await;
|
||||
|
||||
let usage = summary
|
||||
.as_ref()
|
||||
@@ -4455,8 +4603,8 @@ mod tests {
|
||||
assert_eq!(usage.output_tokens, 23);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kiro_stream_summary_does_not_subtract_cache_from_already_billed_input() {
|
||||
#[tokio::test]
|
||||
async fn kiro_stream_summary_does_not_subtract_cache_from_already_billed_input() {
|
||||
let request_body = json!({
|
||||
"model": "claude-opus-4-7",
|
||||
"messages": [
|
||||
@@ -4504,6 +4652,7 @@ mod tests {
|
||||
transport_profile: None,
|
||||
timeouts: None,
|
||||
};
|
||||
let state = test_state();
|
||||
|
||||
let mut summary = Some(ExecutionStreamTerminalSummary {
|
||||
standardized_usage: Some(StandardizedUsage {
|
||||
@@ -4517,10 +4666,12 @@ mod tests {
|
||||
});
|
||||
|
||||
maybe_apply_kiro_prompt_cache_usage_to_stream_summary(
|
||||
&state,
|
||||
&plan,
|
||||
Some(&report_context),
|
||||
&mut summary,
|
||||
);
|
||||
)
|
||||
.await;
|
||||
|
||||
let usage = summary
|
||||
.as_ref()
|
||||
@@ -4640,9 +4791,11 @@ mod tests {
|
||||
"original_request_body": request_body,
|
||||
"kiro_simulated_cache_enabled": true,
|
||||
}));
|
||||
let state = AppState::new().expect("gateway state should build");
|
||||
|
||||
super::seed_kiro_report_context_input_tokens(&plan, &mut report_context);
|
||||
super::seed_kiro_report_context_prompt_cache_usage(&plan, &mut report_context);
|
||||
super::seed_kiro_report_context_prompt_cache_usage(&state, &plan, &mut report_context)
|
||||
.await;
|
||||
|
||||
let context = report_context.as_ref().expect("context should exist");
|
||||
assert!(context
|
||||
@@ -4709,9 +4862,11 @@ mod tests {
|
||||
let mut report_context = Some(json!({
|
||||
"original_request_body": request_body,
|
||||
}));
|
||||
let state = AppState::new().expect("gateway state should build");
|
||||
|
||||
super::seed_kiro_report_context_input_tokens(&plan, &mut report_context);
|
||||
super::seed_kiro_report_context_prompt_cache_usage(&plan, &mut report_context);
|
||||
super::seed_kiro_report_context_prompt_cache_usage(&state, &plan, &mut report_context)
|
||||
.await;
|
||||
|
||||
let context = report_context.as_ref().expect("context should exist");
|
||||
assert!(context
|
||||
|
||||
@@ -41,6 +41,12 @@ use crate::clock::current_unix_ms as current_request_candidate_unix_ms;
|
||||
use crate::control::GatewayControlDecision;
|
||||
use crate::execution_runtime::chatgpt_web_image::maybe_execute_chatgpt_web_image_sync;
|
||||
use crate::execution_runtime::grok::maybe_execute_grok_sync;
|
||||
use crate::execution_runtime::kiro_cache::{
|
||||
build_kiro_prompt_cache_profile, compute_kiro_prompt_cache_usage,
|
||||
estimate_kiro_prompt_input_tokens, kiro_simulated_cache_enabled_from_provider_config,
|
||||
kiro_simulated_cache_enabled_from_report_context, KiroPromptCacheUsage,
|
||||
KIRO_SIMULATED_CACHE_ENABLED_CONTEXT_FIELD,
|
||||
};
|
||||
use crate::execution_runtime::oauth_retry::refresh_oauth_plan_auth_for_retry;
|
||||
#[cfg(test)]
|
||||
use crate::execution_runtime::remote_compat::post_sync_plan_to_remote_execution_runtime;
|
||||
@@ -355,6 +361,180 @@ fn build_sync_report_payload(
|
||||
}
|
||||
}
|
||||
|
||||
fn seed_kiro_sync_report_context_input_tokens(
|
||||
plan: &ExecutionPlan,
|
||||
report_context: &mut Option<Value>,
|
||||
) {
|
||||
if !plan
|
||||
.provider_name
|
||||
.as_deref()
|
||||
.is_some_and(|provider_name| provider_name.eq_ignore_ascii_case("Kiro"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(context) = report_context.as_mut().and_then(Value::as_object_mut) else {
|
||||
return;
|
||||
};
|
||||
if context
|
||||
.get("input_tokens")
|
||||
.and_then(Value::as_u64)
|
||||
.is_some_and(|input_tokens| input_tokens > 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(original_request_body) = context.get("original_request_body").cloned() else {
|
||||
return;
|
||||
};
|
||||
let estimated_input_tokens = estimate_kiro_prompt_input_tokens(&original_request_body);
|
||||
context.insert(
|
||||
"input_tokens".to_string(),
|
||||
Value::from(estimated_input_tokens),
|
||||
);
|
||||
}
|
||||
|
||||
async fn seed_kiro_sync_simulated_cache_enabled(
|
||||
state: &AppState,
|
||||
plan: &ExecutionPlan,
|
||||
report_context: &mut Option<Value>,
|
||||
) {
|
||||
if !plan
|
||||
.provider_name
|
||||
.as_deref()
|
||||
.is_some_and(|provider_name| provider_name.eq_ignore_ascii_case("Kiro"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let enabled = match state
|
||||
.read_provider_catalog_providers_by_ids(std::slice::from_ref(&plan.provider_id))
|
||||
.await
|
||||
{
|
||||
Ok(providers) => providers
|
||||
.iter()
|
||||
.find(|provider| provider.id == plan.provider_id)
|
||||
.filter(|provider| provider.provider_type.eq_ignore_ascii_case("kiro"))
|
||||
.is_some_and(|provider| {
|
||||
kiro_simulated_cache_enabled_from_provider_config(provider.config.as_ref())
|
||||
}),
|
||||
Err(err) => {
|
||||
warn!(
|
||||
event_name = "kiro_simulated_cache_config_read_failed",
|
||||
log_type = "event",
|
||||
request_id = %plan.request_id,
|
||||
provider_id = %plan.provider_id,
|
||||
error = ?err,
|
||||
"failed to read Kiro simulated cache provider config; defaulting disabled"
|
||||
);
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
let Some(context) = report_context.as_mut().and_then(Value::as_object_mut) else {
|
||||
return;
|
||||
};
|
||||
if enabled {
|
||||
context.insert(
|
||||
KIRO_SIMULATED_CACHE_ENABLED_CONTEXT_FIELD.to_string(),
|
||||
Value::Bool(true),
|
||||
);
|
||||
} else {
|
||||
context.remove(KIRO_SIMULATED_CACHE_ENABLED_CONTEXT_FIELD);
|
||||
}
|
||||
}
|
||||
|
||||
async fn seed_kiro_sync_report_context_prompt_cache_usage(
|
||||
state: &AppState,
|
||||
plan: &ExecutionPlan,
|
||||
report_context: &mut Option<Value>,
|
||||
) {
|
||||
if !plan
|
||||
.provider_name
|
||||
.as_deref()
|
||||
.is_some_and(|provider_name| provider_name.eq_ignore_ascii_case("Kiro"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let simulated_cache_enabled =
|
||||
kiro_simulated_cache_enabled_from_report_context(report_context.as_ref());
|
||||
let Some(context) = report_context.as_mut().and_then(Value::as_object_mut) else {
|
||||
return;
|
||||
};
|
||||
if context
|
||||
.get("kiro_web_search_mcp")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if !simulated_cache_enabled {
|
||||
return;
|
||||
}
|
||||
if kiro_cache_usage_from_context_object(context).is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(original_request_body) = context.get("original_request_body").cloned() else {
|
||||
return;
|
||||
};
|
||||
let input_tokens = context
|
||||
.get("input_tokens")
|
||||
.and_then(Value::as_u64)
|
||||
.filter(|value| *value > 0)
|
||||
.unwrap_or_else(|| {
|
||||
let estimated = estimate_kiro_prompt_input_tokens(&original_request_body);
|
||||
context.insert("input_tokens".to_string(), Value::from(estimated));
|
||||
estimated
|
||||
});
|
||||
let Some(profile) = build_kiro_prompt_cache_profile(&original_request_body, input_tokens)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
let cache_usage = compute_kiro_prompt_cache_usage(
|
||||
state.runtime_state(),
|
||||
kiro_sync_cache_credential_id(plan),
|
||||
&profile,
|
||||
)
|
||||
.await;
|
||||
if cache_usage.cache_creation_input_tokens == 0 && cache_usage.cache_read_input_tokens == 0 {
|
||||
return;
|
||||
}
|
||||
context.insert(
|
||||
"cache_creation_input_tokens".to_string(),
|
||||
Value::from(cache_usage.cache_creation_input_tokens),
|
||||
);
|
||||
context.insert(
|
||||
"cache_read_input_tokens".to_string(),
|
||||
Value::from(cache_usage.cache_read_input_tokens),
|
||||
);
|
||||
}
|
||||
|
||||
fn kiro_sync_cache_credential_id(plan: &ExecutionPlan) -> String {
|
||||
format!("{}:{}:{}", plan.provider_id, plan.endpoint_id, plan.key_id)
|
||||
}
|
||||
|
||||
fn kiro_cache_usage_from_context_object(
|
||||
context: &serde_json::Map<String, Value>,
|
||||
) -> Option<KiroPromptCacheUsage> {
|
||||
let cache_creation_input_tokens = context
|
||||
.get("cache_creation_input_tokens")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
let cache_read_input_tokens = context
|
||||
.get("cache_read_input_tokens")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
(cache_creation_input_tokens > 0 || cache_read_input_tokens > 0).then_some(
|
||||
KiroPromptCacheUsage {
|
||||
cache_creation_input_tokens,
|
||||
cache_read_input_tokens,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn invalid_gemini_provider_success_message(
|
||||
plan: &ExecutionPlan,
|
||||
report_context: Option<&Value>,
|
||||
@@ -1934,8 +2114,13 @@ async fn execute_execution_runtime_sync_impl(
|
||||
}
|
||||
let status_code = result.status_code;
|
||||
let has_body_bytes = body_base64.is_some();
|
||||
let report_context =
|
||||
let mut report_context =
|
||||
attach_provider_response_headers_to_report_context(report_context, &headers);
|
||||
if (200..300).contains(&status_code) {
|
||||
seed_kiro_sync_report_context_input_tokens(&plan, &mut report_context);
|
||||
seed_kiro_sync_simulated_cache_enabled(state, &plan, &mut report_context).await;
|
||||
seed_kiro_sync_report_context_prompt_cache_usage(state, &plan, &mut report_context).await;
|
||||
}
|
||||
let mut client_headers = headers.clone();
|
||||
apply_endpoint_response_header_rules(state, &plan, &mut client_headers, body_json.as_ref())
|
||||
.await?;
|
||||
@@ -2504,6 +2689,45 @@ mod tests {
|
||||
plan
|
||||
}
|
||||
|
||||
fn test_kiro_sync_plan() -> ExecutionPlan {
|
||||
ExecutionPlan {
|
||||
request_id: "req-kiro-sync-cache-1".to_string(),
|
||||
candidate_id: Some("candidate-kiro-sync-cache-1".to_string()),
|
||||
provider_name: Some("Kiro".to_string()),
|
||||
provider_id: "provider-kiro-sync-1".to_string(),
|
||||
endpoint_id: "endpoint-kiro-sync-1".to_string(),
|
||||
key_id: "key-kiro-sync-1".to_string(),
|
||||
method: "POST".to_string(),
|
||||
url: "https://kiro.example/generateAssistantResponse".to_string(),
|
||||
headers: BTreeMap::new(),
|
||||
content_type: Some("application/json".to_string()),
|
||||
content_encoding: None,
|
||||
body: aether_contracts::RequestBody::from_json(json!({
|
||||
"model": "claude-sonnet-4",
|
||||
"messages": [{"role": "user", "content": "hello kiro"}],
|
||||
})),
|
||||
stream: false,
|
||||
client_api_format: "claude:messages".to_string(),
|
||||
provider_api_format: "claude:messages".to_string(),
|
||||
model_name: Some("claude-sonnet-4".to_string()),
|
||||
proxy: None,
|
||||
transport_profile: None,
|
||||
timeouts: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn test_kiro_sync_cacheable_request_body() -> serde_json::Value {
|
||||
json!({
|
||||
"model": "claude-sonnet-4",
|
||||
"system": [{
|
||||
"type": "text",
|
||||
"text": format!("sync cacheable prompt {}", "cacheable prompt chunk ".repeat(300)),
|
||||
"cache_control": {"type": "ephemeral"}
|
||||
}],
|
||||
"messages": [{"role": "user", "content": "reuse this Kiro prompt"}]
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_gemini_provider_success_uses_plan_format_when_context_is_missing() {
|
||||
let plan = test_gemini_chat_plan();
|
||||
@@ -2659,6 +2883,68 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kiro_sync_report_context_seeds_input_tokens_from_original_request_body() {
|
||||
let plan = test_kiro_sync_plan();
|
||||
let mut report_context = Some(json!({
|
||||
"original_request_body": test_kiro_sync_cacheable_request_body(),
|
||||
}));
|
||||
|
||||
seed_kiro_sync_report_context_input_tokens(&plan, &mut report_context);
|
||||
|
||||
assert!(report_context
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("input_tokens"))
|
||||
.and_then(Value::as_u64)
|
||||
.is_some_and(|tokens| tokens > 0));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn kiro_sync_report_context_applies_prompt_cache_usage_from_tracker() {
|
||||
let state = AppState::new().expect("gateway state should build");
|
||||
let plan = test_kiro_sync_plan();
|
||||
|
||||
let mut first_report_context = Some(json!({
|
||||
"original_request_body": test_kiro_sync_cacheable_request_body(),
|
||||
"kiro_simulated_cache_enabled": true,
|
||||
}));
|
||||
seed_kiro_sync_report_context_input_tokens(&plan, &mut first_report_context);
|
||||
seed_kiro_sync_report_context_prompt_cache_usage(&state, &plan, &mut first_report_context)
|
||||
.await;
|
||||
let first_creation = first_report_context
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("cache_creation_input_tokens"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or_default();
|
||||
let first_read = first_report_context
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("cache_read_input_tokens"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or_default();
|
||||
assert!(first_creation > 0);
|
||||
assert_eq!(first_read, 0);
|
||||
|
||||
let mut second_report_context = Some(json!({
|
||||
"original_request_body": test_kiro_sync_cacheable_request_body(),
|
||||
"kiro_simulated_cache_enabled": true,
|
||||
}));
|
||||
seed_kiro_sync_report_context_input_tokens(&plan, &mut second_report_context);
|
||||
seed_kiro_sync_report_context_prompt_cache_usage(&state, &plan, &mut second_report_context)
|
||||
.await;
|
||||
let second_creation = second_report_context
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("cache_creation_input_tokens"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or_default();
|
||||
let second_read = second_report_context
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("cache_read_input_tokens"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or_default();
|
||||
assert_eq!(second_creation, 0);
|
||||
assert!(second_read > 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn json_whitespace_heartbeat_stream_prefixes_final_json() {
|
||||
let (tx, rx) = mpsc::channel::<Result<Bytes, IoError>>(1);
|
||||
|
||||
@@ -8,6 +8,9 @@ use super::{
|
||||
StoredProviderCatalogKey, StoredProviderCatalogProvider, StoredProviderModelMapping,
|
||||
DEVELOPMENT_ENCRYPTION_KEY, TRACE_ID_HEADER,
|
||||
};
|
||||
use aether_data::repository::usage::InMemoryUsageReadRepository;
|
||||
use aether_data_contracts::repository::usage::{StoredRequestUsageAudit, UsageReadRepository};
|
||||
use aether_usage_runtime::UsageRuntimeConfig;
|
||||
|
||||
const KIRO_CLAUDE_CLI_SYNC_TEST_STACK_BYTES: usize = 16 * 1024 * 1024;
|
||||
|
||||
@@ -33,6 +36,30 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_completed_usage<T>(repository: &T, request_id: &str) -> StoredRequestUsageAudit
|
||||
where
|
||||
T: UsageReadRepository + ?Sized,
|
||||
{
|
||||
let timeout = std::time::Duration::from_secs(60);
|
||||
let deadline = tokio::time::Instant::now() + timeout;
|
||||
loop {
|
||||
if let Some(usage) = repository
|
||||
.find_by_request_id(request_id)
|
||||
.await
|
||||
.expect("usage should read")
|
||||
{
|
||||
if usage.status == "completed" {
|
||||
return usage;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
tokio::time::Instant::now() < deadline,
|
||||
"usage {request_id} should complete within {timeout:?}"
|
||||
);
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gateway_executes_kiro_claude_cli_sync_via_local_provider_catalog_candidate() {
|
||||
run_kiro_claude_cli_sync_test(
|
||||
@@ -194,7 +221,7 @@ async fn gateway_executes_kiro_claude_cli_sync_via_local_provider_catalog_candid
|
||||
Some(serde_json::json!({"url":"http://provider-proxy.internal:8080"})),
|
||||
Some(20.0),
|
||||
None,
|
||||
None,
|
||||
Some(serde_json::json!({"kiro": {"simulated_cache_enabled": true}})),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -354,14 +381,15 @@ async fn gateway_executes_kiro_claude_cli_sync_via_local_provider_catalog_candid
|
||||
let raw_body = to_bytes(body, usize::MAX).await.expect("body should read");
|
||||
let payload: serde_json::Value =
|
||||
serde_json::from_slice(&raw_body).expect("execution runtime payload should parse");
|
||||
let trace_id = parts
|
||||
.headers
|
||||
.get(TRACE_ID_HEADER)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
*seen_execution_runtime_inner.lock().expect("mutex should lock") =
|
||||
Some(SeenExecutionRuntimeSyncRequest {
|
||||
trace_id: parts
|
||||
.headers
|
||||
.get(TRACE_ID_HEADER)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
trace_id: trace_id.clone(),
|
||||
url: payload
|
||||
.get("url")
|
||||
.and_then(|value| value.as_str())
|
||||
@@ -453,7 +481,7 @@ async fn gateway_executes_kiro_claude_cli_sync_via_local_provider_catalog_candid
|
||||
.concat();
|
||||
|
||||
Json(json!({
|
||||
"request_id": "trace-kiro-cli-local-sync-123",
|
||||
"request_id": trace_id,
|
||||
"status_code": 200,
|
||||
"headers": {
|
||||
"content-type": "application/vnd.amazon.eventstream"
|
||||
@@ -481,6 +509,7 @@ async fn gateway_executes_kiro_claude_cli_sync_via_local_provider_catalog_candid
|
||||
sample_candidate_row(),
|
||||
]));
|
||||
let request_candidate_repository = Arc::new(InMemoryRequestCandidateRepository::default());
|
||||
let usage_repository = Arc::new(InMemoryUsageReadRepository::default());
|
||||
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![sample_provider_catalog_provider()],
|
||||
vec![sample_provider_catalog_endpoint()],
|
||||
@@ -491,34 +520,51 @@ async fn gateway_executes_kiro_claude_cli_sync_via_local_provider_catalog_candid
|
||||
let (execution_runtime_url, execution_runtime_handle) = start_server(execution_runtime).await;
|
||||
let gateway_state = build_state_with_execution_runtime_override(execution_runtime_url.clone())
|
||||
.with_data_state_for_tests(
|
||||
crate::data::GatewayDataState::with_auth_candidate_selection_provider_catalog_and_request_candidate_repository_for_tests(
|
||||
crate::data::GatewayDataState::with_auth_candidate_selection_provider_catalog_request_candidates_and_usage_for_tests(
|
||||
auth_repository,
|
||||
candidate_selection_repository,
|
||||
provider_catalog_repository,
|
||||
Arc::clone(&request_candidate_repository),
|
||||
Arc::clone(&usage_repository),
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
),
|
||||
);
|
||||
)
|
||||
.with_usage_runtime_for_tests(UsageRuntimeConfig {
|
||||
enabled: true,
|
||||
..UsageRuntimeConfig::default()
|
||||
});
|
||||
let gateway = build_router_with_state(gateway_state);
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.post(format!("{gateway_url}/v1/messages"))
|
||||
.header(http::header::CONTENT_TYPE, "application/json")
|
||||
.header(
|
||||
http::header::AUTHORIZATION,
|
||||
"Bearer sk-client-kiro-cli-local-sync",
|
||||
)
|
||||
.header(TRACE_ID_HEADER, "trace-kiro-cli-local-sync-123")
|
||||
.body(
|
||||
"{\"model\":\"claude-sonnet-4\",\"messages\":[{\"role\":\"user\",\"content\":\"hello kiro\"}],\"thinking\":{\"type\":\"enabled\",\"budget_tokens\":64}}",
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
async fn send_kiro_request(
|
||||
gateway_url: &str,
|
||||
trace_id: &str,
|
||||
body: String,
|
||||
) -> (StatusCode, String) {
|
||||
let response = reqwest::Client::new()
|
||||
.post(format!("{gateway_url}/v1/messages"))
|
||||
.header(http::header::CONTENT_TYPE, "application/json")
|
||||
.header(
|
||||
http::header::AUTHORIZATION,
|
||||
"Bearer sk-client-kiro-cli-local-sync",
|
||||
)
|
||||
.header(TRACE_ID_HEADER, trace_id)
|
||||
.body(body)
|
||||
.send()
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
let status = response.status();
|
||||
let response_body = response.text().await.expect("body should read");
|
||||
let status = response.status();
|
||||
let response_body = response.text().await.expect("body should read");
|
||||
(status, response_body)
|
||||
}
|
||||
|
||||
let (status, response_body) = send_kiro_request(
|
||||
&gateway_url,
|
||||
"trace-kiro-cli-local-sync-123",
|
||||
"{\"model\":\"claude-sonnet-4\",\"messages\":[{\"role\":\"user\",\"content\":\"hello kiro\"}],\"thinking\":{\"type\":\"enabled\",\"budget_tokens\":64}}".to_string(),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
status == StatusCode::OK,
|
||||
"unexpected status={status} body={response_body} decision_hits={} plan_hits={} public_hits={}",
|
||||
@@ -598,6 +644,58 @@ async fn gateway_executes_kiro_claude_cli_sync_via_local_provider_catalog_candid
|
||||
"report-sync should stay local when request candidate persistence is available"
|
||||
);
|
||||
|
||||
let cacheable_request_body = serde_json::json!({
|
||||
"model": "claude-sonnet-4",
|
||||
"system": [{
|
||||
"type": "text",
|
||||
"text": format!("sync cacheable prompt {}", "cacheable prompt chunk ".repeat(300)),
|
||||
"cache_control": {"type": "ephemeral"}
|
||||
}],
|
||||
"messages": [{"role": "user", "content": "reuse this Kiro prompt"}]
|
||||
})
|
||||
.to_string();
|
||||
let (first_cache_status, first_cache_body) = send_kiro_request(
|
||||
&gateway_url,
|
||||
"trace-kiro-cli-local-sync-cache-1",
|
||||
cacheable_request_body.clone(),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
first_cache_status == StatusCode::OK,
|
||||
"unexpected first cache status={first_cache_status} body={first_cache_body}"
|
||||
);
|
||||
let first_usage = wait_for_completed_usage(
|
||||
usage_repository.as_ref(),
|
||||
"trace-kiro-cli-local-sync-cache-1",
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
first_usage.cache_creation_input_tokens > 0,
|
||||
"first Kiro sync cacheable request should create simulated cache"
|
||||
);
|
||||
assert_eq!(first_usage.cache_read_input_tokens, 0);
|
||||
|
||||
let (second_cache_status, second_cache_body) = send_kiro_request(
|
||||
&gateway_url,
|
||||
"trace-kiro-cli-local-sync-cache-2",
|
||||
cacheable_request_body,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
second_cache_status == StatusCode::OK,
|
||||
"unexpected second cache status={second_cache_status} body={second_cache_body}"
|
||||
);
|
||||
let second_usage = wait_for_completed_usage(
|
||||
usage_repository.as_ref(),
|
||||
"trace-kiro-cli-local-sync-cache-2",
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
second_usage.cache_read_input_tokens > 0,
|
||||
"second Kiro sync cacheable request should read simulated cache"
|
||||
);
|
||||
assert_eq!(second_usage.cache_creation_input_tokens, 0);
|
||||
|
||||
assert_eq!(*decision_hits.lock().expect("mutex should lock"), 0);
|
||||
assert_eq!(*plan_hits.lock().expect("mutex should lock"), 0);
|
||||
assert_eq!(*public_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
@@ -146,6 +146,7 @@ pub struct SyncTerminalUsagePayloadSeed {
|
||||
pub provider_response_body_state: Option<UsageBodyCaptureState>,
|
||||
pub client_response: Option<Value>,
|
||||
pub client_response_body_state: Option<UsageBodyCaptureState>,
|
||||
pub standardized_usage: Option<StandardizedUsage>,
|
||||
pub capture_metadata: Option<Value>,
|
||||
}
|
||||
|
||||
@@ -766,6 +767,7 @@ pub fn build_sync_terminal_usage_payload_seed(
|
||||
.or_else(|| headers_to_json(&payload.headers));
|
||||
let client_response_headers = context_usage_value(context, "client_response_headers")
|
||||
.or_else(|| headers_to_json(&payload.headers));
|
||||
let standardized_usage = kiro_simulated_cache_standardized_usage_from_context(context);
|
||||
SyncTerminalUsagePayloadSeed {
|
||||
report_kind: payload.report_kind.clone(),
|
||||
status_code: payload.status_code,
|
||||
@@ -780,6 +782,7 @@ pub fn build_sync_terminal_usage_payload_seed(
|
||||
provider_response_body_state,
|
||||
client_response,
|
||||
client_response_body_state,
|
||||
standardized_usage,
|
||||
capture_metadata: build_payload_body_capture_metadata(
|
||||
payload.body_base64.as_deref(),
|
||||
None,
|
||||
@@ -856,11 +859,14 @@ pub fn build_sync_terminal_usage_seed(
|
||||
provider_response_body_state,
|
||||
client_response,
|
||||
client_response_body_state,
|
||||
standardized_usage,
|
||||
capture_metadata,
|
||||
} = payload_seed;
|
||||
let standardized_usage = provider_response_full
|
||||
let derived_standardized_usage = provider_response_full
|
||||
.as_ref()
|
||||
.map(|response| map_usage_from_response(response, context_seed.provider_contract.as_str()));
|
||||
let standardized_usage =
|
||||
merge_standardized_usage_with_context_cache(standardized_usage, derived_standardized_usage);
|
||||
let terminal_state = infer_sync_terminal_state(
|
||||
report_kind.as_str(),
|
||||
status_code,
|
||||
@@ -914,6 +920,25 @@ pub fn build_sync_terminal_usage_seed(
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_standardized_usage_with_context_cache(
|
||||
context_usage: Option<StandardizedUsage>,
|
||||
derived_usage: Option<StandardizedUsage>,
|
||||
) -> Option<StandardizedUsage> {
|
||||
let Some(context_usage) = context_usage else {
|
||||
return derived_usage;
|
||||
};
|
||||
|
||||
let mut usage = derived_usage.unwrap_or_default();
|
||||
usage.input_tokens = context_usage.input_tokens;
|
||||
if context_usage.cache_creation_tokens > 0 {
|
||||
usage.cache_creation_tokens = context_usage.cache_creation_tokens;
|
||||
}
|
||||
if context_usage.cache_read_tokens > 0 {
|
||||
usage.cache_read_tokens = context_usage.cache_read_tokens;
|
||||
}
|
||||
Some(usage)
|
||||
}
|
||||
|
||||
pub fn build_stream_terminal_usage_seed(
|
||||
context_seed: TerminalUsageContextSeed,
|
||||
payload_seed: StreamTerminalUsagePayloadSeed,
|
||||
@@ -1760,6 +1785,31 @@ fn context_body_value(context: Option<&Map<String, Value>>, key: &str) -> Option
|
||||
}
|
||||
}
|
||||
|
||||
fn kiro_simulated_cache_standardized_usage_from_context(
|
||||
context: Option<&Map<String, Value>>,
|
||||
) -> Option<StandardizedUsage> {
|
||||
let enabled = context_bool(context, "kiro_simulated_cache_enabled").unwrap_or(false);
|
||||
if !enabled {
|
||||
return None;
|
||||
}
|
||||
|
||||
let input_tokens = context_u64(context, "input_tokens")?;
|
||||
let cache_creation_tokens = context_u64(context, "cache_creation_input_tokens").unwrap_or(0);
|
||||
let cache_read_tokens = context_u64(context, "cache_read_input_tokens").unwrap_or(0);
|
||||
if cache_creation_tokens == 0 && cache_read_tokens == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let billed_input_tokens = input_tokens
|
||||
.saturating_sub(cache_creation_tokens)
|
||||
.saturating_sub(cache_read_tokens);
|
||||
let mut usage = StandardizedUsage::new();
|
||||
usage.input_tokens = billed_input_tokens as i64;
|
||||
usage.cache_creation_tokens = cache_creation_tokens as i64;
|
||||
usage.cache_read_tokens = cache_read_tokens as i64;
|
||||
Some(usage)
|
||||
}
|
||||
|
||||
fn context_has_inline_body(context: Option<&Map<String, Value>>, key: &str) -> bool {
|
||||
matches!(context_value_ref(context, key), Some(value) if !value.is_null())
|
||||
}
|
||||
@@ -5543,6 +5593,64 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_terminal_usage_applies_kiro_simulated_cache_context() {
|
||||
let plan = ExecutionPlan {
|
||||
request_id: "req-sync-kiro-cache-context-1".to_string(),
|
||||
candidate_id: Some("cand-sync-kiro-cache-context-1".to_string()),
|
||||
provider_name: Some("Kiro".to_string()),
|
||||
provider_id: "provider-kiro-1".to_string(),
|
||||
endpoint_id: "endpoint-kiro-1".to_string(),
|
||||
key_id: "key-kiro-1".to_string(),
|
||||
method: "POST".to_string(),
|
||||
url: "https://kiro.example/generateAssistantResponse".to_string(),
|
||||
headers: BTreeMap::new(),
|
||||
content_type: Some("application/json".to_string()),
|
||||
content_encoding: None,
|
||||
body: RequestBody::from_json(json!({
|
||||
"model": "claude-sonnet-4",
|
||||
"messages": [{"role": "user", "content": "hello kiro"}],
|
||||
})),
|
||||
stream: false,
|
||||
client_api_format: "claude:messages".to_string(),
|
||||
provider_api_format: "claude:messages".to_string(),
|
||||
model_name: Some("claude-sonnet-4".to_string()),
|
||||
proxy: None,
|
||||
transport_profile: None,
|
||||
timeouts: None,
|
||||
};
|
||||
let payload = GatewaySyncReportRequest {
|
||||
trace_id: "trace-sync-kiro-cache-context-1".to_string(),
|
||||
report_kind: "claude_cli_sync_success".to_string(),
|
||||
report_context: Some(json!({
|
||||
"client_api_format": "claude:messages",
|
||||
"provider_api_format": "claude:messages",
|
||||
"provider_name": "Kiro",
|
||||
"model": "claude-sonnet-4",
|
||||
"input_tokens": 1800,
|
||||
"kiro_simulated_cache_enabled": true,
|
||||
"cache_creation_input_tokens": 1200,
|
||||
"cache_read_input_tokens": 300,
|
||||
})),
|
||||
status_code: 200,
|
||||
headers: BTreeMap::new(),
|
||||
body_json: Some(json!({"id": "kiro-sync-response-1"})),
|
||||
client_body_json: None,
|
||||
body_base64: None,
|
||||
telemetry: None,
|
||||
};
|
||||
|
||||
let event =
|
||||
build_sync_terminal_usage_event(&plan, payload.report_context.as_ref(), &payload)
|
||||
.expect("usage event should build");
|
||||
|
||||
assert_eq!(event.event_type, UsageEventType::Completed);
|
||||
assert_eq!(event.data.input_tokens, Some(300));
|
||||
assert_eq!(event.data.cache_creation_input_tokens, Some(1200));
|
||||
assert_eq!(event.data.cache_read_input_tokens, Some(300));
|
||||
assert_eq!(event.data.total_tokens, Some(300));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_terminal_usage_treats_null_error_field_as_success() {
|
||||
let plan = ExecutionPlan {
|
||||
|
||||
Reference in New Issue
Block a user