use std::collections::HashMap;
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde_json::Value;
use super::{number_key, string_key};
use crate::telemetry::host::{self as host_telemetry, HostContextSnapshot};
const GEMINI_CONTEXT_WINDOW_VARS: &[&str] = &[
"CCD_GEMINI_CONTEXT_WINDOW_TOKENS",
"GEMINI_CONTEXT_WINDOW_TOKENS",
];
#[derive(Default)]
struct GeminiSessionSnapshot {
latest_prompt_tokens: Option<u64>,
input_tokens: u64,
output_tokens: u64,
cache_read_input_tokens: u64,
context_window: Option<u64>,
compacted: bool,
model_name: Option<String>,
}
pub(crate) fn current(repo_root: &Path) -> Result<Option<HostContextSnapshot>> {
let telemetry_path = match telemetry_path(repo_root)? {
Some(path) => path,
None => return Ok(None),
};
let observed_at_epoch_s = match host_telemetry::file_mtime_epoch_s(&telemetry_path)? {
Some(epoch_s) => epoch_s,
None => return Ok(None),
};
if host_telemetry::env_string(&["CCD_GEMINI_TELEMETRY_PATH", "GEMINI_TELEMETRY_OUTFILE"])
.is_none()
&& !host_telemetry::is_recent(observed_at_epoch_s)?
{
return Ok(None);
}
let file = File::open(&telemetry_path)
.with_context(|| format!("failed to open {}", telemetry_path.display()))?;
let reader = BufReader::new(file);
let mut sessions: HashMap<String, GeminiSessionSnapshot> = HashMap::new();
let mut latest_session_key = None;
for line in reader.lines() {
let line = line.with_context(|| format!("failed to read {}", telemetry_path.display()))?;
if line.trim().is_empty() {
continue;
}
let value: Value = match serde_json::from_str(&line) {
Ok(value) => value,
Err(_) => continue,
};
let event_name = event_name(&value).unwrap_or_else(|| {
if line.contains("gemini_cli.api_response") {
"gemini_cli.api_response".to_owned()
} else if line.contains("gemini_cli.chat_compression") {
"gemini_cli.chat_compression".to_owned()
} else {
String::new()
}
});
if event_name.is_empty() {
continue;
}
let session_key =
string_key(&value, &["session_id", "sessionId"]).unwrap_or_else(|| "global".to_owned());
latest_session_key = Some(session_key.clone());
let session = sessions.entry(session_key).or_default();
if event_name.contains("gemini_cli.api_response") {
if let Some(prompt_tokens) = prompt_tokens(&value) {
session.latest_prompt_tokens = Some(prompt_tokens);
}
if let Some(input_tokens) = input_tokens(&value) {
session.input_tokens = session.input_tokens.saturating_add(input_tokens);
}
if let Some(output_tokens) = output_tokens(&value) {
session.output_tokens = session.output_tokens.saturating_add(output_tokens);
}
if let Some(cache_read_tokens) = cache_read_input_tokens(&value) {
session.cache_read_input_tokens = session
.cache_read_input_tokens
.saturating_add(cache_read_tokens);
}
session.context_window = number_key(
&value,
&[
"model_context_window",
"context_window",
"contextWindow",
"limit_context",
],
)
.or_else(|| host_telemetry::env_u64(GEMINI_CONTEXT_WINDOW_VARS));
if let Some(model_name) =
string_key(&value, &["model", "model_name", "modelName", "model_id"])
{
session.model_name = Some(model_name);
}
}
if event_name.contains("gemini_cli.chat_compression") {
session.compacted = true;
}
}
let Some(session_key) = latest_session_key else {
return Ok(None);
};
let Some(session) = sessions.remove(&session_key) else {
return Ok(None);
};
if session.latest_prompt_tokens.is_none() && !session.compacted {
return Ok(None);
}
Ok(Some(HostContextSnapshot {
host: "gemini",
observed_at_epoch_s,
model_name: session
.model_name
.or_else(|| host_telemetry::env_string(&["CCD_GEMINI_MODEL", "GEMINI_MODEL"]))
.or_else(|| host_telemetry::env_string(&["CCD_HOST_MODEL"])),
context_used_pct: session
.latest_prompt_tokens
.and_then(|total| host_telemetry::compute_pct(total, session.context_window)),
total_tokens: session.latest_prompt_tokens,
model_context_window: session.context_window,
compacted: session.compacted.then_some(true),
cost_usage: host_telemetry::HostCostUsage {
input_tokens: session.input_tokens,
output_tokens: session.output_tokens,
cache_creation_input_tokens: 0,
cache_read_input_tokens: session.cache_read_input_tokens,
blended_total_tokens: None,
},
}))
}
fn telemetry_path(repo_root: &Path) -> Result<Option<PathBuf>> {
if let Some(path) =
host_telemetry::env_string(&["CCD_GEMINI_TELEMETRY_PATH", "GEMINI_TELEMETRY_OUTFILE"])
{
let path = PathBuf::from(path);
return Ok(path.is_file().then_some(path));
}
let candidates = [
repo_root.join(".gemini/telemetry.log"),
repo_root.join("telemetry.log"),
host_telemetry::home_dir()?.join(".gemini/telemetry.log"),
];
Ok(candidates.into_iter().find(|path| path.is_file()))
}
fn event_name(value: &Value) -> Option<String> {
string_key(value, &["name", "event_name", "eventName"])
}
fn prompt_tokens(value: &Value) -> Option<u64> {
let input_tokens = input_tokens(value).unwrap_or(0);
let cached_tokens = cache_read_input_tokens(value).unwrap_or(0);
let total = input_tokens.saturating_add(cached_tokens);
(total > 0).then_some(total)
}
fn input_tokens(value: &Value) -> Option<u64> {
number_key(
value,
&[
"input_token_count",
"inputTokens",
"prompt_tokens",
"promptTokenCount",
],
)
}
fn output_tokens(value: &Value) -> Option<u64> {
number_key(
value,
&[
"output_token_count",
"outputTokens",
"completion_tokens",
"completionTokenCount",
],
)
}
fn cache_read_input_tokens(value: &Value) -> Option<u64> {
number_key(
value,
&[
"cached_content_token_count",
"cacheReadInputTokens",
"cachedTokens",
],
)
}