use super::utils::file_modified_timestamp_ms;
use super::UnifiedMessage;
use crate::TokenBreakdown;
use chrono::{Local, LocalResult, NaiveDateTime, TimeZone};
use serde::Deserialize;
use sha2::{Digest, Sha256};
use std::io::Read;
use std::path::Path;
const USAGE_MARKER: &[u8] = b"\"usage\"";
const SERVER_LOG_WINDOW_BYTES: usize = 8 * 1024 * 1024;
const SERVER_LOG_CHUNK_BYTES: usize = 64 * 1024;
const IDENTITY_OVERLAP_BYTES: usize = 512;
#[derive(Debug, Default, Deserialize)]
struct PromptTokenDetails {
#[serde(default, alias = "cache_read_tokens")]
cached_tokens: i64,
#[serde(default, alias = "cache_write_tokens")]
cache_creation_input_tokens: i64,
}
#[derive(Debug, Default, Deserialize)]
struct OutputTokenDetails {
#[serde(default)]
reasoning_tokens: i64,
}
#[derive(Debug, Default, Deserialize)]
struct UsagePayload {
#[serde(
default,
alias = "promptTokens",
alias = "input_tokens",
alias = "inputTokens"
)]
prompt_tokens: i64,
#[serde(
default,
alias = "completionTokens",
alias = "output_tokens",
alias = "outputTokens"
)]
completion_tokens: i64,
#[serde(default, alias = "totalTokens")]
total_tokens: i64,
#[serde(default, alias = "input_tokens_details", alias = "inputTokensDetails")]
prompt_tokens_details: PromptTokenDetails,
#[serde(
default,
alias = "completion_tokens_details",
alias = "completionTokensDetails",
alias = "outputTokensDetails"
)]
output_tokens_details: OutputTokenDetails,
#[serde(default)]
cached_tokens: i64,
#[serde(default)]
cache_creation_input_tokens: i64,
#[serde(default)]
reasoning_tokens: i64,
}
fn find_bytes(haystack: &[u8], needle: &[u8], from: usize) -> Option<usize> {
if needle.is_empty() || from > haystack.len().saturating_sub(needle.len()) {
return None;
}
haystack[from..]
.windows(needle.len())
.position(|window| window == needle)
.map(|offset| from + offset)
}
fn skip_ascii_whitespace(bytes: &[u8], mut index: usize) -> usize {
while bytes
.get(index)
.is_some_and(|byte| byte.is_ascii_whitespace())
{
index += 1;
}
index
}
fn scan_usage_object_start(bytes: &[u8], from: usize) -> (Option<(usize, usize)>, usize) {
let mut cursor = from;
while let Some(marker) = find_bytes(bytes, USAGE_MARKER, cursor) {
cursor = marker + USAGE_MARKER.len();
if marker > 0 && bytes[marker - 1] == b'\\' {
continue;
}
let value = skip_ascii_whitespace(bytes, cursor);
if value >= bytes.len() {
return (None, marker);
}
if bytes[value] != b':' {
continue;
}
let brace = skip_ascii_whitespace(bytes, value + 1);
if brace >= bytes.len() {
return (None, marker);
}
if bytes[brace] == b'{' {
return (Some((marker, brace)), marker);
}
}
(
None,
bytes
.len()
.saturating_sub(USAGE_MARKER.len().saturating_sub(1)),
)
}
fn balanced_object_end(bytes: &[u8], start: usize) -> Option<usize> {
if bytes.get(start) != Some(&b'{') {
return None;
}
let mut depth = 0usize;
let mut in_string = false;
let mut escaped = false;
for (index, byte) in bytes.iter().copied().enumerate().skip(start) {
if in_string {
if escaped {
escaped = false;
} else if byte == b'\\' {
escaped = true;
} else if byte == b'"' {
in_string = false;
}
continue;
}
match byte {
b'"' => in_string = true,
b'{' => depth += 1,
b'}' => {
depth = depth.saturating_sub(1);
if depth == 0 {
return Some(index + 1);
}
}
_ => {}
}
}
None
}
fn json_string_end(bytes: &[u8], start: usize) -> Option<usize> {
if bytes.get(start) != Some(&b'"') {
return None;
}
let mut escaped = false;
for (index, byte) in bytes.iter().copied().enumerate().skip(start + 1) {
if escaped {
escaped = false;
} else if byte == b'\\' {
escaped = true;
} else if byte == b'"' {
return Some(index + 1);
}
}
None
}
fn last_json_string_field(bytes: &[u8], field: &[u8]) -> Option<String> {
last_matching_json_string_field(bytes, field, |_| true)
}
fn last_matching_json_string_field(
bytes: &[u8],
field: &[u8],
accepts: impl Fn(&str) -> bool,
) -> Option<String> {
let mut marker = Vec::with_capacity(field.len() + 2);
marker.push(b'"');
marker.extend_from_slice(field);
marker.push(b'"');
let mut cursor = 0usize;
let mut found = None;
while let Some(index) = find_bytes(bytes, &marker, cursor) {
cursor = index + marker.len();
if index > 0 && bytes[index - 1] == b'\\' {
continue;
}
let mut value = skip_ascii_whitespace(bytes, cursor);
if bytes.get(value) != Some(&b':') {
continue;
}
value = skip_ascii_whitespace(bytes, value + 1);
let Some(end) = json_string_end(bytes, value) else {
continue;
};
if let Ok(parsed) = serde_json::from_slice::<String>(&bytes[value..end]) {
if accepts(&parsed) {
found = Some(parsed);
}
}
}
found
}
fn last_log_timestamp(bytes: &[u8]) -> Option<i64> {
let text = String::from_utf8_lossy(bytes);
let mut parsed = None;
for line in text.split('\n') {
let line = line.strip_prefix('\r').unwrap_or(line);
let Some(rest) = line.strip_prefix('[') else {
continue;
};
let Some(end) = rest.find(']') else {
continue;
};
if !rest[end + 1..].starts_with('[') {
continue;
}
let Ok(naive) = NaiveDateTime::parse_from_str(&rest[..end], "%Y-%m-%d %H:%M:%S") else {
continue;
};
parsed = match Local.from_local_datetime(&naive) {
LocalResult::Single(value) => Some(value.timestamp_millis()),
LocalResult::Ambiguous(first, _) => Some(first.timestamp_millis()),
LocalResult::None => parsed,
};
}
parsed
}
fn non_negative(value: i64) -> i64 {
value.max(0)
}
fn normalized_tokens(usage: &UsagePayload) -> Option<TokenBreakdown> {
let prompt = non_negative(usage.prompt_tokens);
let output = non_negative(usage.completion_tokens);
let cache_read = non_negative(
usage
.prompt_tokens_details
.cached_tokens
.max(usage.cached_tokens),
)
.min(prompt);
let cache_write = non_negative(
usage
.prompt_tokens_details
.cache_creation_input_tokens
.max(usage.cache_creation_input_tokens),
)
.min(prompt.saturating_sub(cache_read));
let reasoning = non_negative(
usage
.output_tokens_details
.reasoning_tokens
.max(usage.reasoning_tokens),
)
.min(output);
let total = non_negative(usage.total_tokens).max(prompt.saturating_add(output));
if total == 0 {
return None;
}
let input = total
.saturating_sub(output)
.saturating_sub(cache_read)
.saturating_sub(cache_write);
Some(TokenBreakdown {
input,
output: output.saturating_sub(reasoning),
cache_read,
cache_write,
reasoning,
})
}
fn source_hash(path: &Path) -> String {
let mut hasher = Sha256::new();
hasher.update(path.to_string_lossy().as_bytes());
format!("{:x}", hasher.finalize())[..12].to_string()
}
fn fallback_dedup_key(path: &Path, marker: usize, model: &str, tokens: &TokenBreakdown) -> String {
let mut hasher = Sha256::new();
hasher.update(path.to_string_lossy().as_bytes());
hasher.update(marker.to_le_bytes());
hasher.update(model.as_bytes());
for value in [
tokens.input,
tokens.output,
tokens.cache_read,
tokens.cache_write,
] {
hasher.update(value.to_le_bytes());
}
format!("lmstudio:{:x}", hasher.finalize())
}
pub fn parse_lmstudio_file(path: &Path) -> Vec<UnifiedMessage> {
parse_lmstudio_file_windowed(path, SERVER_LOG_WINDOW_BYTES, SERVER_LOG_CHUNK_BYTES)
}
fn parse_lmstudio_file_windowed(
path: &Path,
window_bytes: usize,
chunk_bytes: usize,
) -> Vec<UnifiedMessage> {
let Ok(file) = std::fs::File::open(path) else {
return Vec::new();
};
let mut reader = std::io::BufReader::new(file);
let fallback_timestamp = file_modified_timestamp_ms(path);
let session_id = format!("lmstudio:{}", source_hash(path));
let mut messages = Vec::new();
let mut window: Vec<u8> = Vec::new();
let mut window_start = 0usize;
let mut metadata_start = 0usize;
let mut scanned_to = 0usize;
let mut chunk = vec![0u8; chunk_bytes.max(1)];
let mut carried = CarriedIdentity::default();
loop {
let read = reader.read(&mut chunk).unwrap_or(0);
if read > 0 {
window.extend_from_slice(&chunk[..read]);
}
let at_eof = read == 0;
loop {
let scan_from = scanned_to.saturating_sub(window_start).min(window.len());
let (found, certain_to) = scan_usage_object_start(&window, scan_from);
let Some((marker, object_start)) = found else {
scanned_to = window_start + certain_to;
break;
};
let Some(object_end) = balanced_object_end(&window, object_start) else {
scanned_to = if at_eof {
window_start + window.len()
} else {
window_start + marker
};
break;
};
scanned_to = window_start + object_end;
let absolute_marker = window_start + marker;
let absolute_end = window_start + object_end;
let metadata_from = metadata_start.saturating_sub(window_start).min(marker);
if let Some(message) = message_from_record(
&window[object_start..object_end],
&window[metadata_from..marker],
&carried,
path,
absolute_marker,
&session_id,
fallback_timestamp,
) {
messages.push(message);
}
metadata_start = absolute_end;
carried.clear();
}
let keep_from = metadata_start
.max(window_start)
.saturating_sub(window_start)
.min(window.len());
if keep_from > 0 {
window.drain(..keep_from);
window_start += keep_from;
}
if window.len() > window_bytes {
let overflow = window.len() - window_bytes;
let absorb_to = overflow
.saturating_add(IDENTITY_OVERLAP_BYTES)
.min(window.len());
carried.absorb(&window[..absorb_to]);
window.drain(..overflow);
window_start += overflow;
metadata_start = metadata_start.max(window_start);
scanned_to = scanned_to.max(window_start);
}
if at_eof {
break;
}
}
messages
}
#[derive(Default)]
struct CarriedIdentity {
response_id: Option<String>,
model: Option<String>,
timestamp: Option<i64>,
}
impl CarriedIdentity {
fn absorb(&mut self, bytes: &[u8]) {
if let Some(id) = response_id_in(bytes) {
self.response_id = Some(id);
}
if let Some(model) = model_in(bytes) {
self.model = Some(model);
}
if let Some(timestamp) = last_log_timestamp(bytes) {
self.timestamp = Some(timestamp);
}
}
fn clear(&mut self) {
*self = Self::default();
}
}
fn response_id_in(bytes: &[u8]) -> Option<String> {
last_matching_json_string_field(bytes, b"id", |value| {
["chatcmpl-", "cmpl-", "resp_"]
.iter()
.any(|prefix| value.starts_with(prefix))
})
}
fn model_in(bytes: &[u8]) -> Option<String> {
last_json_string_field(bytes, b"model").filter(|value| !value.trim().is_empty())
}
fn message_from_record(
usage_object: &[u8],
metadata: &[u8],
carried: &CarriedIdentity,
path: &Path,
marker: usize,
session_id: &str,
fallback_timestamp: i64,
) -> Option<UnifiedMessage> {
let usage = serde_json::from_slice::<UsagePayload>(usage_object).ok()?;
let tokens = normalized_tokens(&usage)?;
let response_id = response_id_in(metadata).or_else(|| carried.response_id.clone());
let model = model_in(metadata)
.or_else(|| carried.model.clone())
.unwrap_or_else(|| "unknown".to_string());
let timestamp = last_log_timestamp(metadata)
.or(carried.timestamp)
.unwrap_or(fallback_timestamp);
if timestamp <= 0 {
return None;
}
let dedup_key = response_id
.map(|id| format!("lmstudio:{id}"))
.unwrap_or_else(|| fallback_dedup_key(path, marker, &model, &tokens));
let mut message = UnifiedMessage::new_with_dedup(
"lmstudio",
model,
"lmstudio",
session_id.to_string(),
timestamp,
tokens,
0.0,
Some(dedup_key),
);
message.mark_provider_reported_cost();
message.is_turn_start = true;
Some(message)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
#[test]
fn response_identity_survives_nested_tool_call_id() {
let mut file = NamedTempFile::new().unwrap();
writeln!(file, r#"[2026-07-09 10:00:00][INFO]
Final response: {{"id":"chatcmpl-123","model":"local-model","choices":[{{"message":{{"role":"assistant","tool_calls":[{{"id":"call_456","type":"function","function":{{"name":"test","arguments":"{{}}"}}}}]}}}}],"usage":{{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}}}}"#).unwrap();
let messages = parse_lmstudio_file(file.path());
assert_eq!(messages.len(), 1);
assert_eq!(
messages[0].dedup_key.as_deref(),
Some("lmstudio:chatcmpl-123")
);
}
fn padded_log(index: usize, pad_bytes: usize) -> String {
format!(
"[filler] {}\n[2026-07-09 10:00:{:02}][INFO][fixture-model]\n\
Final response: {{\"id\":\"chatcmpl-{index}\",\"model\":\"fixture-model\",\
\"usage\":{{\"prompt_tokens\":{},\"completion_tokens\":5,\"total_tokens\":{}}}}}\n",
"p".repeat(pad_bytes),
index % 60,
10 + index,
15 + index,
)
}
#[test]
fn reads_every_record_in_a_log_larger_than_the_window() {
let mut file = NamedTempFile::new().unwrap();
let records = 40;
for i in 0..records {
file.write_all(padded_log(i, 200).as_bytes()).unwrap();
}
file.flush().unwrap();
let size = file.as_file().metadata().unwrap().len() as usize;
let window = 512;
let chunk = 64;
assert!(size > window * 10, "fixture must dwarf the window");
let messages = parse_lmstudio_file_windowed(file.path(), window, chunk);
assert_eq!(
messages.len(),
records,
"every record must survive a log the parse cannot hold at once"
);
for (i, message) in messages.iter().enumerate() {
assert_eq!(message.tokens.input, 10 + i as i64);
assert_eq!(
message.dedup_key.as_deref(),
Some(&*format!("lmstudio:chatcmpl-{i}"))
);
}
}
#[test]
fn window_and_chunk_sizes_do_not_change_the_result() {
let mut file = NamedTempFile::new().unwrap();
for i in 0..12 {
file.write_all(padded_log(i, 300).as_bytes()).unwrap();
}
file.flush().unwrap();
let reference = parse_lmstudio_file(file.path());
assert_eq!(reference.len(), 12);
for (window, chunk) in [(512, 1), (1024, 7), (4096, 64), (1 << 20, 1 << 16)] {
let windowed = parse_lmstudio_file_windowed(file.path(), window, chunk);
assert_eq!(
windowed, reference,
"window={window} chunk={chunk} changed the parse"
);
}
}
#[test]
fn a_response_that_outgrows_the_window_keeps_its_identity() {
let mut file = NamedTempFile::new().unwrap();
let body = "b".repeat(4096);
write!(
file,
"[2026-07-09 10:00:00][INFO][fixture-model]\n\
Final response: {{\"id\":\"chatcmpl-far\",\"model\":\"big-model\",\
\"choices\":[{{\"text\":\"{body}\"}}],\
\"usage\":{{\"prompt_tokens\":31,\"completion_tokens\":7,\"total_tokens\":38}}}}\n"
)
.unwrap();
file.flush().unwrap();
let messages = parse_lmstudio_file_windowed(file.path(), 512, 64);
assert_eq!(messages.len(), 1, "the record must survive eviction");
let message = &messages[0];
assert_eq!(
message.dedup_key.as_deref(),
Some("lmstudio:chatcmpl-far"),
"the response id must survive its own body"
);
assert_eq!(message.model_id, "big-model");
assert_eq!(message.tokens.input, 31);
assert_eq!(message.date, "2026-07-09");
assert_eq!(messages, parse_lmstudio_file(file.path()));
}
#[test]
fn a_timestamp_printed_by_the_model_is_not_taken_as_the_log_time() {
let mut file = NamedTempFile::new().unwrap();
write!(
file,
"[2026-07-09 10:00:00][INFO][fixture-model]\n\
Final response: {{\"id\":\"chatcmpl-quoted\",\"model\":\"fixture-model\",\
\"choices\":[{{\"text\":\"the log said [2019-03-04 05:06:07] earlier\"}}],\
\"usage\":{{\"prompt_tokens\":12,\"completion_tokens\":3,\"total_tokens\":15}}}}\n"
)
.unwrap();
file.flush().unwrap();
let messages = parse_lmstudio_file(file.path());
assert_eq!(messages.len(), 1);
assert_eq!(
messages[0].date, "2026-07-09",
"the header dates the usage, not a date the model printed"
);
}
#[test]
fn a_record_larger_than_the_window_does_not_take_the_file_with_it() {
let mut file = NamedTempFile::new().unwrap();
file.write_all(padded_log(0, 32).as_bytes()).unwrap();
file.write_all(padded_log(1, 4096).as_bytes()).unwrap();
file.write_all(padded_log(2, 32).as_bytes()).unwrap();
file.flush().unwrap();
let keys: Vec<String> = parse_lmstudio_file_windowed(file.path(), 512, 64)
.into_iter()
.filter_map(|m| m.dedup_key)
.collect();
assert!(
keys.contains(&"lmstudio:chatcmpl-0".to_string()),
"records before an oversized one survive: {keys:?}"
);
assert!(
keys.contains(&"lmstudio:chatcmpl-2".to_string()),
"records after an oversized one survive: {keys:?}"
);
}
#[test]
fn parses_exact_components_and_marks_local_cost_authoritative() {
let mut file = NamedTempFile::new().unwrap();
write!(
file,
r#"[2026-07-09 10:00:00][INFO][fixture-model]
Final response: {{
"id": "chatcmpl-fixture",
"model": "fixture-model",
"choices": [{{"message": {{"content": "synthetic {{ braces }}"}}}}],
"usage": {{
"prompt_tokens": 100,
"completion_tokens": 12,
"total_tokens": 112,
"prompt_tokens_details": {{"cached_tokens": 40, "cache_creation_input_tokens": 10}}
}}
}}
"#
)
.unwrap();
file.flush().unwrap();
let messages = parse_lmstudio_file(file.path());
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].client, "lmstudio");
assert_eq!(messages[0].model_id, "fixture-model");
assert_eq!(messages[0].tokens.input, 50);
assert_eq!(messages[0].tokens.output, 12);
assert_eq!(messages[0].tokens.cache_read, 40);
assert_eq!(messages[0].tokens.cache_write, 10);
assert_eq!(messages[0].tokens.total(), 112);
assert_eq!(messages[0].cost, 0.0);
assert!(messages[0].has_authoritative_cost());
assert_eq!(
messages[0].dedup_key.as_deref(),
Some("lmstudio:chatcmpl-fixture")
);
}
#[test]
fn keeps_distinct_identical_usage_and_skips_partial_or_zero_records() {
let mut file = NamedTempFile::new().unwrap();
for id in ["chatcmpl-a", "chatcmpl-b"] {
writeln!(
file,
"[2026-07-09 11:00:00][INFO][m]\n{}",
serde_json::json!({
"id": id,
"model": "m",
"usage": {"prompt_tokens": 7, "completion_tokens": 3, "total_tokens": 10}
})
)
.unwrap();
}
writeln!(file, "{{\"usage\":{{\"prompt_tokens\":0}}}}").unwrap();
write!(file, "{{\"usage\":{{\"prompt_tokens\":9").unwrap();
file.flush().unwrap();
let messages = parse_lmstudio_file(file.path());
assert_eq!(messages.len(), 2);
assert_ne!(messages[0].dedup_key, messages[1].dedup_key);
assert_eq!(
messages
.iter()
.map(|message| message.tokens.total())
.sum::<i64>(),
20
);
}
#[test]
fn parses_responses_api_usage_without_double_counting_reasoning() {
let mut file = NamedTempFile::new().unwrap();
writeln!(
file,
"[2026-07-09 11:30:00][INFO][responses-model]\n{}",
serde_json::json!({
"id": "resp_fixture",
"model": "responses-model",
"output": [{"type": "reasoning"}, {"type": "message"}],
"usage": {
"input_tokens": 100,
"output_tokens": 40,
"total_tokens": 140,
"input_tokens_details": {"cached_tokens": 30},
"output_tokens_details": {"reasoning_tokens": 10}
}
})
)
.unwrap();
file.flush().unwrap();
let messages = parse_lmstudio_file(file.path());
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].model_id, "responses-model");
assert_eq!(messages[0].tokens.input, 70);
assert_eq!(messages[0].tokens.output, 30);
assert_eq!(messages[0].tokens.cache_read, 30);
assert_eq!(messages[0].tokens.cache_write, 0);
assert_eq!(messages[0].tokens.reasoning, 10);
assert_eq!(messages[0].tokens.total(), 140);
assert_eq!(
messages[0].dedup_key.as_deref(),
Some("lmstudio:resp_fixture")
);
}
#[test]
fn ignores_response_content_that_looks_like_metadata() {
let mut file = NamedTempFile::new().unwrap();
writeln!(
file,
"[2026-07-09 11:00:00][INFO][real-model]\n{}",
serde_json::json!({
"id": "chatcmpl-real",
"model": "real-model",
"choices": [{
"message": {
"content": r#"{"id":"chatcmpl-fake","model":"fake-model"}"#
}
}],
"usage": {
"prompt_tokens": 7,
"completion_tokens": 3,
"total_tokens": 10
}
})
)
.unwrap();
file.flush().unwrap();
let messages = parse_lmstudio_file(file.path());
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].model_id, "real-model");
assert_eq!(
messages[0].dedup_key.as_deref(),
Some("lmstudio:chatcmpl-real")
);
}
#[test]
fn uses_reported_total_without_losing_component_closure() {
let usage = UsagePayload {
prompt_tokens: 20,
completion_tokens: 5,
total_tokens: 30,
prompt_tokens_details: PromptTokenDetails {
cached_tokens: 8,
cache_creation_input_tokens: 2,
},
..UsagePayload::default()
};
let tokens = normalized_tokens(&usage).unwrap();
assert_eq!(tokens.input, 15);
assert_eq!(tokens.total(), 30);
}
}