use std::hash::{DefaultHasher, Hash, Hasher};
use crate::shared::api::{ApiMessage, ChatRequest};
use crate::shared::tokens::floor_text;
const TOLERANCE: u64 = 32;
const MARKUP: u64 = 16;
#[derive(Debug, Clone)]
pub(super) struct RequestShape {
model: Option<String>,
tools: u64,
system: String,
messages: Vec<MessageShape>,
}
#[derive(Debug, Clone, Copy)]
struct MessageShape {
hash: u64,
most: u64,
least: u64,
images: bool,
}
#[derive(Debug, Clone)]
pub(super) struct PromptAnchor {
shape: RequestShape,
processed: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct PromptCut {
pub(super) processed: u32,
pub(super) held: u64,
}
impl PromptCut {
pub(super) fn judge(processed: u32, held: u64) -> Option<Self> {
(processed as u64 + TOLERANCE < held).then_some(Self { processed, held })
}
}
impl RequestShape {
pub(super) fn of(request: &ChatRequest, model: Option<&str>) -> Self {
let tools = if request.tools.is_empty() {
0
} else {
hash_of(&crate::shared::api::openai::tools_json(&request.tools))
};
Self {
model: model.map(str::to_string),
tools,
system: request.system.clone().unwrap_or_default(),
messages: request.messages.iter().map(MessageShape::of).collect(),
}
}
pub(super) fn held(&self, anchor: Option<&PromptAnchor>) -> u64 {
let own = floor_text(&self.system) + self.messages.iter().map(|m| m.least).sum::<u64>();
own.max(anchor.and_then(|a| self.extending(a)).unwrap_or(0))
}
pub(super) fn served(self, processed: u32) -> PromptAnchor {
PromptAnchor {
shape: self,
processed,
}
}
fn extending(&self, anchor: &PromptAnchor) -> Option<u64> {
let before = &anchor.shape;
if self.model != before.model || self.tools != before.tools {
return None;
}
let common = self
.messages
.iter()
.zip(&before.messages)
.take_while(|(now, then)| now.hash == then.hash)
.count();
let removed = &before.messages[common..];
if removed.iter().any(|m| m.images) {
return None;
}
let (system_removed, system_added) = system_change(&before.system, &self.system);
let removed = removed.iter().map(|m| m.most).sum::<u64>() + system_removed;
let added = self.messages[common..].iter().map(|m| m.least).sum::<u64>() + system_added;
Some((anchor.processed as u64 + added).saturating_sub(removed))
}
}
impl MessageShape {
fn of(message: &ApiMessage) -> Self {
let mut h = DefaultHasher::new();
(message.role as u8).hash(&mut h);
message.content.hash(&mut h);
message.tool_call_id.hash(&mut h);
let mut most = message.content.len() as u64 + MARKUP;
for call in &message.tool_calls {
call.id.hash(&mut h);
call.name.hash(&mut h);
call.arguments.hash(&mut h);
most += (call.name.len() + call.arguments.len()) as u64 + MARKUP;
}
for image in &message.images {
image.mime.hash(&mut h);
image.data.hash(&mut h);
image.label.hash(&mut h);
}
Self {
hash: h.finish(),
most,
least: floor_text(&message.content),
images: !message.images.is_empty(),
}
}
}
fn system_change(before: &str, now: &str) -> (u64, u64) {
let (a, b) = (before.as_bytes(), now.as_bytes());
let mut head = a.iter().zip(b).take_while(|(x, y)| x == y).count();
while !now.is_char_boundary(head) {
head -= 1;
}
let room = a.len().min(b.len()) - head;
let mut tail = a
.iter()
.rev()
.zip(b.iter().rev())
.take(room)
.take_while(|(x, y)| x == y)
.count();
while !now.is_char_boundary(b.len() - tail) {
tail -= 1;
}
let removed = (a.len() - head - tail) as u64;
let added = floor_text(&now[head..b.len() - tail]);
(removed, added)
}
fn hash_of(text: &str) -> u64 {
let mut h = DefaultHasher::new();
text.hash(&mut h);
h.finish()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::shared::api::{ApiImage, ApiToolCall, ToolSchema};
fn request(system: &str, messages: Vec<ApiMessage>) -> ChatRequest {
ChatRequest {
system: Some(system.to_string()),
messages,
..Default::default()
}
}
fn prose(words: usize) -> String {
(0..words)
.map(|i| format!("word{i}"))
.collect::<Vec<_>>()
.join(" ")
}
fn anchor(req: &ChatRequest, processed: u32) -> PromptAnchor {
RequestShape::of(req, Some("m")).served(processed)
}
#[test]
fn a_figure_within_the_tolerance_is_not_a_cut() {
assert_eq!(PromptCut::judge(1000, 1032), None);
assert_eq!(
PromptCut::judge(1000, 1033),
Some(PromptCut {
processed: 1000,
held: 1033
})
);
assert_eq!(PromptCut::judge(4000, 100), None);
}
#[test]
fn a_request_that_extends_the_last_is_bounded_by_its_exact_size() {
let first = request("sys", vec![ApiMessage::user("hello")]);
let mut next = first.clone();
next.messages.push(ApiMessage::assistant(prose(200)));
next.messages.push(ApiMessage::user(prose(100)));
let shape = RequestShape::of(&next, Some("m"));
let added = floor_text(&prose(200)) + floor_text(&prose(100));
assert_eq!(
shape.held(Some(&anchor(&first, 3500))),
3500 + added,
"the exact size plus the floor of what was appended"
);
assert!(PromptCut::judge(2051, shape.held(Some(&anchor(&first, 3500)))).is_some());
}
#[test]
fn without_an_anchor_the_request_is_its_own_floor() {
let req = request(&prose(50), vec![ApiMessage::user(prose(300))]);
let shape = RequestShape::of(&req, Some("m"));
assert_eq!(
shape.held(None),
floor_text(&prose(50)) + floor_text(&prose(300))
);
}
#[test]
fn the_tool_schemas_count_nothing_toward_the_floor() {
let mut req = request("sys", vec![ApiMessage::user("hi")]);
let bare = RequestShape::of(&req, Some("m")).held(None);
req.tools = vec![ToolSchema {
name: "t".into(),
description: prose(500),
parameters: serde_json::json!({"type": "object"}),
}];
assert_eq!(RequestShape::of(&req, Some("m")).held(None), bare);
}
#[test]
fn what_a_regeneration_removed_is_taken_off_at_its_bytes() {
let base = request("sys", vec![ApiMessage::user("question")]);
let mut with_round = base.clone();
let call = ApiToolCall {
id: "c1".into(),
name: "note_save".into(),
arguments: r#"{"content":"x"}"#.into(),
thought_signature: None,
};
with_round
.messages
.push(ApiMessage::assistant_tool_calls("", vec![call]));
with_round.messages.push(ApiMessage::tool("c1", prose(100)));
let removed =
(MARKUP + "note_save".len() as u64 + 15 + MARKUP) + (prose(100).len() as u64 + MARKUP);
let shape = RequestShape::of(&base, Some("m"));
assert_eq!(shape.held(Some(&anchor(&with_round, 2000))), 2000 - removed);
}
#[test]
fn an_edit_near_the_start_leaves_nothing_to_bound() {
let before = request(
"sys",
vec![
ApiMessage::user(prose(400)),
ApiMessage::assistant(prose(400)),
ApiMessage::user("next"),
],
);
let after = request("sys", vec![ApiMessage::user("edited")]);
let shape = RequestShape::of(&after, Some("m"));
assert_eq!(shape.held(Some(&anchor(&before, 1200))), shape.held(None));
}
#[test]
fn the_system_text_changing_in_the_middle_costs_only_the_middle() {
let head = prose(100);
let tail = prose(80);
let before = request(
&format!("{head} at 10:00, notes A B {tail}"),
vec![ApiMessage::user("q")],
);
let now_system = format!("{head} at 10:05, notes C {tail}");
let after = request(&now_system, vec![ApiMessage::user("q")]);
let (removed, added) = system_change(before.system.as_deref().unwrap(), &now_system);
assert_eq!(removed, "0, notes A B".len() as u64);
assert_eq!(added, floor_text("5, notes C"));
assert_eq!(
RequestShape::of(&after, Some("m")).held(Some(&anchor(&before, 900))),
900 - removed + added
);
}
#[test]
fn a_system_change_splits_on_a_character_boundary() {
let (removed, added) = system_change("а т б", "а ц б");
assert_eq!(removed, "т".len() as u64);
assert_eq!(added, floor_text("ц"));
assert_eq!(system_change("same", "same"), (0, 0));
assert_eq!(system_change("", "new text"), (0, floor_text("new text")));
assert_eq!(system_change("old text", ""), ("old text".len() as u64, 0));
}
#[test]
fn another_model_or_other_tools_give_no_bound() {
let first = request("sys", vec![ApiMessage::user(prose(300))]);
let mut next = first.clone();
next.messages.push(ApiMessage::user("more"));
let own = RequestShape::of(&next, Some("m")).held(None);
let other_model = RequestShape::of(&first, Some("other")).served(5000);
assert_eq!(
RequestShape::of(&next, Some("m")).held(Some(&other_model)),
own
);
let mut with_tools = first.clone();
with_tools.tools = vec![ToolSchema {
name: "t".into(),
description: "d".into(),
parameters: serde_json::json!({}),
}];
assert_eq!(
RequestShape::of(&next, Some("m")).held(Some(&anchor(&with_tools, 5000))),
own
);
}
#[test]
fn a_removed_image_leaves_the_request_unbounded() {
let image = ApiImage::new("image/png", "AAAA", None);
let before = request(
"sys",
vec![ApiMessage::user("look").with_images(vec![image])],
);
let after = request("sys", vec![ApiMessage::user("look")]);
let shape = RequestShape::of(&after, Some("m"));
assert_eq!(shape.held(Some(&anchor(&before, 3000))), shape.held(None));
let mut kept = before.clone();
kept.messages.push(ApiMessage::user("and this"));
assert_eq!(
RequestShape::of(&kept, Some("m")).held(Some(&anchor(&before, 3000))),
3000 + floor_text("and this")
);
}
}