Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6537,6 +6537,9 @@ impl App {
let agent = self.agent.clone();
let original_messages = messages;
let task_session_id = session_id.to_string();
let compaction_pricing = self.discovery.as_ref().and_then(|discovery| {
discovery.get_model_pricing(&self.provider_name.to_lowercase(), &self.model)
});

tokio::spawn(async move {
let result = crate::llm::client::summarize_for_compaction(
Expand All @@ -6557,7 +6560,7 @@ impl App {
let mut messages = crate::session::compaction::apply_soft_compaction(
&original_messages,
&selection,
&summary,
&summary.text,
Some(model),
Some(provider_name),
Some(agent),
Expand All @@ -6568,6 +6571,27 @@ impl App {
after_messages: 0,
},
);
let cost = compaction_pricing
.as_ref()
.map(|pricing| {
pricing.estimate_tokens(
summary.usage.input,
summary.usage.output,
summary.usage.cache_read,
summary.usage.cache_write,
)
})
.unwrap_or(0.0);
crate::session::compaction::attach_summary_usage(
&mut messages,
crate::session::types::RecordedUsage {
input: summary.usage.input,
output: summary.usage.output,
cache_read: summary.usage.cache_read,
cache_write: summary.usage.cache_write,
cost,
},
);
// Count post-boundary context only (new layout:
// [history][summary][tail…][marker] — marker excluded).
let after_tokens = crate::session::compaction::total_context_tokens(&messages);
Expand Down
148 changes: 112 additions & 36 deletions src/llm/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -914,13 +914,57 @@ fn resolve_api_key(
configured_api_key(auth_config).or(custom_provider_api_key)
}

#[derive(Debug, Clone, Default, PartialEq)]
pub struct CompactionSummary {
pub text: String,
pub usage: crate::aisdk::chunk::TokenUsage,
}

fn apply_compaction_stream_chunk(
summary: &mut String,
usage: &mut crate::aisdk::chunk::TokenUsage,
chunk: ChunkType,
) -> Result<(), DynError> {
match chunk {
ChunkType::Text(text) => summary.push_str(&text),
ChunkType::Failed(err) => {
return Err(anyhow::anyhow!("Compaction failed: {}", err).into());
}
ChunkType::NotSupported(msg) => {
return Err(anyhow::anyhow!("Compaction unsupported: {}", msg).into());
}
ChunkType::Usage(chunk_usage) => {
*usage = usage.saturating_add(chunk_usage);
}
ChunkType::StreamRollback { text, .. } => {
if summary.ends_with(&text) {
summary.truncate(summary.len() - text.len());
}
}
ChunkType::Reasoning(_)
| ChunkType::ReasoningItem(_)
| ChunkType::ToolCall(_)
| ChunkType::ProviderToolCall(_)
| ChunkType::End { .. }
| ChunkType::AssistantMessagePhase { .. }
| ChunkType::ResponseCompleted { .. }
| ChunkType::Retry(_)
| ChunkType::RetryableFailure(_)
| ChunkType::Warning(_)
| ChunkType::Metadata(_)
| ChunkType::Start
| ChunkType::Incomplete(_) => {}
}
Ok(())
}

pub async fn summarize_for_compaction(
provider_name: String,
model: String,
reasoning_effort: Option<crate::model::reasoning::ReasoningEffort>,
prompt: String,
cancel_token: CancellationToken,
) -> Result<String, DynError> {
) -> Result<CompactionSummary, DynError> {
if cancel_token.is_cancelled() {
return Err(anyhow::anyhow!("Compaction cancelled by user").into());
}
Expand All @@ -939,6 +983,7 @@ pub async fn summarize_for_compaction(
.await?;

let mut summary = String::new();
let mut usage = crate::aisdk::chunk::TokenUsage::default();
loop {
let chunk = tokio::select! {
_ = cancel_token.cancelled() => {
Expand All @@ -951,34 +996,7 @@ pub async fn summarize_for_compaction(
break;
};

match chunk {
ChunkType::Text(text) => summary.push_str(&text),
ChunkType::Failed(err) => {
return Err(anyhow::anyhow!("Compaction failed: {}", err).into());
}
ChunkType::NotSupported(msg) => {
return Err(anyhow::anyhow!("Compaction unsupported: {}", msg).into());
}
ChunkType::Reasoning(_)
| ChunkType::ReasoningItem(_)
| ChunkType::ToolCall(_)
| ChunkType::ProviderToolCall(_)
| ChunkType::End { .. }
| ChunkType::AssistantMessagePhase { .. }
| ChunkType::ResponseCompleted { .. }
| ChunkType::Retry(_)
| ChunkType::RetryableFailure(_)
| ChunkType::Warning(_)
| ChunkType::Metadata(_)
| ChunkType::Usage(_)
| ChunkType::Start
| ChunkType::Incomplete(_) => {}
ChunkType::StreamRollback { text, .. } => {
if summary.ends_with(&text) {
summary.truncate(summary.len() - text.len());
}
}
}
apply_compaction_stream_chunk(&mut summary, &mut usage, chunk)?;
}

if cancel_token.is_cancelled() {
Expand All @@ -990,7 +1008,10 @@ pub async fn summarize_for_compaction(
return Err(anyhow::anyhow!("Compaction returned an empty summary").into());
}

Ok(summary)
Ok(CompactionSummary {
text: summary,
usage,
})
}

pub async fn generate_session_title(
Expand Down Expand Up @@ -2602,16 +2623,71 @@ fn normalize_anthropic_base_url(base_url: &str) -> String {
#[cfg(test)]
mod tests {
use super::{
apply_provider_request_defaults, convert_messages, convert_messages_for_model,
is_openai_oauth_model_allowed, maybe_apply_unauthenticated_free_provider_key,
model_supports_image_input, openai_oauth_default_originator,
openai_oauth_model_uses_responses_lite, openai_request_instructions, resolve_api_key,
resolve_model_route, ui_vs_request_model_mismatch_warning, vlm_agent_has_model,
AisdkMessage, OpenAIRequestOptions, ProviderKind, ProviderRequestConfig,
apply_compaction_stream_chunk, apply_provider_request_defaults, convert_messages,
convert_messages_for_model, is_openai_oauth_model_allowed,
maybe_apply_unauthenticated_free_provider_key, model_supports_image_input,
openai_oauth_default_originator, openai_oauth_model_uses_responses_lite,
openai_request_instructions, resolve_api_key, resolve_model_route,
ui_vs_request_model_mismatch_warning, vlm_agent_has_model, AisdkMessage,
OpenAIRequestOptions, ProviderKind, ProviderRequestConfig,
};
use crate::aisdk::core::chunk::ChunkType;

use crate::persistence::AuthConfig;

#[test]
fn compaction_stream_accumulates_usage_and_text() {
let mut summary = String::new();
let mut usage = crate::aisdk::chunk::TokenUsage::default();

apply_compaction_stream_chunk(&mut summary, &mut usage, ChunkType::Text("hello ".into()))
.unwrap();
apply_compaction_stream_chunk(
&mut summary,
&mut usage,
ChunkType::Usage(crate::aisdk::chunk::TokenUsage {
input: 1_000,
output: 40,
cache_read: 200,
cache_write: 10,
}),
)
.unwrap();
apply_compaction_stream_chunk(
&mut summary,
&mut usage,
ChunkType::Usage(crate::aisdk::chunk::TokenUsage {
input: 50,
output: 10,
cache_read: 0,
cache_write: 0,
}),
)
.unwrap();
apply_compaction_stream_chunk(&mut summary, &mut usage, ChunkType::Text("world".into()))
.unwrap();
apply_compaction_stream_chunk(
&mut summary,
&mut usage,
ChunkType::StreamRollback {
text: "world".into(),
reasoning: String::new(),
},
)
.unwrap();

assert_eq!(summary, "hello ");
assert_eq!(
usage,
crate::aisdk::chunk::TokenUsage {
input: 1_050,
output: 50,
cache_read: 200,
cache_write: 10,
}
);
}

#[test]
fn stored_auth_takes_precedence_over_custom_provider_api_key() {
assert_eq!(
Expand Down
46 changes: 42 additions & 4 deletions src/persistence/conversions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ impl From<SessionMessage> for Message {
// Move the owned parts instead of cloning them: this conversion runs
// for the whole transcript on every streaming snapshot.
let usage = msg.recorded_usage();
let is_compaction_summary = crate::session::compaction::is_compaction_summary(&msg);
let mut parts: Vec<PersistenceMessagePart> = if msg.parts.is_empty() {
let mut parts = Vec::new();
if !msg.content.is_empty() {
Expand Down Expand Up @@ -71,6 +72,20 @@ impl From<SessionMessage> for Message {
});
}

// Compaction summaries store billed prompt/completion on a usage part
// for stats/cost. `tokens_used` is the context estimate (summary text),
// not billed buckets — otherwise reload inflates the model window.
let tokens_used = if is_compaction_summary {
msg.token_count
.map(|count| count.min(i32::MAX as usize) as i32)
.unwrap_or(0)
} else {
usage
.map(|usage| usage.tokens().min(i32::MAX as u64) as i32)
.or(msg.token_count.map(|c| c as i32))
.unwrap_or(0)
};

Message {
id: cuid2::create_id(),
session_id: 0,
Expand All @@ -86,10 +101,7 @@ impl From<SessionMessage> for Message {
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64,
tokens_used: usage
.map(|usage| usage.tokens().min(i32::MAX as u64) as i32)
.or(msg.token_count.map(|c| c as i32))
.unwrap_or(0),
tokens_used,
model: msg.model.clone(),
provider: msg.provider.clone(),
agent_mode: msg.agent_mode.clone(),
Expand Down Expand Up @@ -320,4 +332,30 @@ mod tests {
.iter()
.any(|part| part.part_type == "usage"));
}

#[test]
fn compaction_summary_keeps_context_token_count_not_billed_usage() {
let mut summary = SessionMessage::user(format!(
"{}\n{}",
crate::session::compaction::SUMMARY_PREFIX,
"handoff summary"
));
summary.token_count = Some(40);
summary
.parts
.push(SessionMessagePart::usage(80_000, 400, 12_000, 1_000, 0.42));

let persistence_message: Message = summary.into();
assert_eq!(persistence_message.tokens_used, 40);
assert!(persistence_message
.parts
.iter()
.any(|part| part.part_type == "usage"));

let restored = SessionMessage::try_from(persistence_message).unwrap();
assert_eq!(restored.token_count, Some(40));
let usage = restored.recorded_usage().unwrap();
assert_eq!(usage.input, 80_000);
assert_eq!(usage.output, 400);
}
}
Loading
Loading