use std::fs;
use std::path::{Path, PathBuf};
use serde_json::Value;
use super::{
EnvAlias, PressureObservation, TelemetryError, TelemetryResult, TokenUsage, compute_pct,
general_context_window, general_host_model, home_dir, number_key, resolve_env_string,
resolve_env_u64,
};
const ADAPTER_ID: &str = "opencode";
const OPENCODE_DB_PATH_ALIASES: &[EnvAlias] = &[EnvAlias {
lifeloop: "LIFELOOP_OPENCODE_DB_PATH",
ccd_compat: "CCD_OPENCODE_DB_PATH",
}];
const OPENCODE_DATA_DIR_ALIASES: &[EnvAlias] = &[
EnvAlias {
lifeloop: "LIFELOOP_OPENCODE_DATA_DIR",
ccd_compat: "CCD_OPENCODE_DATA_DIR",
},
EnvAlias {
lifeloop: "LIFELOOP_OPENCODE_DATA_DIR_NATIVE",
ccd_compat: "OPENCODE_DATA_DIR",
},
];
const OPENCODE_MODEL_ALIASES: &[EnvAlias] = &[EnvAlias {
lifeloop: "LIFELOOP_OPENCODE_MODEL",
ccd_compat: "CCD_OPENCODE_MODEL",
}];
const OPENCODE_CONTEXT_WINDOW_ALIASES: &[EnvAlias] = &[EnvAlias {
lifeloop: "LIFELOOP_OPENCODE_CONTEXT_WINDOW_TOKENS",
ccd_compat: "CCD_OPENCODE_CONTEXT_WINDOW_TOKENS",
}];
#[derive(Debug, Clone)]
pub struct SessionMetrics {
pub prompt_tokens: u64,
pub completion_tokens: u64,
pub summary_message_id: Option<String>,
}
pub fn build_observation(
metrics: SessionMetrics,
observed_at_epoch_s: u64,
context_window_tokens: Option<u64>,
model_name: Option<String>,
) -> PressureObservation {
let context_window = context_window_tokens
.or_else(|| resolve_env_u64(OPENCODE_CONTEXT_WINDOW_ALIASES))
.or_else(general_context_window);
let model_name = model_name
.or_else(|| resolve_env_string(OPENCODE_MODEL_ALIASES))
.or_else(general_host_model);
PressureObservation {
adapter_id: ADAPTER_ID.into(),
adapter_version: None,
observed_at_epoch_s,
model_name,
total_tokens: Some(metrics.prompt_tokens),
context_window_tokens: context_window,
context_used_pct: compute_pct(metrics.prompt_tokens, context_window),
compaction_signal: metrics
.summary_message_id
.as_deref()
.filter(|v| !v.is_empty())
.map(|_| true),
usage: TokenUsage {
input_tokens: metrics.prompt_tokens,
output_tokens: metrics.completion_tokens,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
blended_total_tokens: None,
},
}
}
pub fn read_context_window_from_config(bytes: &[u8]) -> Option<u64> {
let value: Value = serde_json::from_slice(bytes).ok()?;
find_limit_context(&value)
}
pub fn read_model_name_from_config(bytes: &[u8]) -> Option<String> {
let value: Value = serde_json::from_slice(bytes).ok()?;
find_model_name(&value)
}
pub fn resolve_context_window(repo_root: &Path) -> TelemetryResult<Option<u64>> {
if let Some(value) = resolve_env_u64(OPENCODE_CONTEXT_WINDOW_ALIASES) {
return Ok(Some(value));
}
for path in opencode_config_candidates(repo_root)? {
if !path.is_file() {
continue;
}
let bytes = fs::read(&path).map_err(TelemetryError::from)?;
if let Some(window) = read_context_window_from_config(&bytes) {
return Ok(Some(window));
}
}
Ok(general_context_window())
}
pub fn resolve_model_name(repo_root: &Path) -> TelemetryResult<Option<String>> {
for path in opencode_config_candidates(repo_root)? {
if !path.is_file() {
continue;
}
let bytes = fs::read(&path).map_err(TelemetryError::from)?;
if let Some(name) = read_model_name_from_config(&bytes) {
return Ok(Some(name));
}
}
Ok(resolve_env_string(OPENCODE_MODEL_ALIASES).or_else(general_host_model))
}
pub fn database_path_hint() -> Option<PathBuf> {
resolve_env_string(OPENCODE_DB_PATH_ALIASES).map(PathBuf::from)
}
pub fn data_dir_hint() -> TelemetryResult<PathBuf> {
if let Some(path) = resolve_env_string(OPENCODE_DATA_DIR_ALIASES) {
return Ok(PathBuf::from(path));
}
Ok(home_dir()?.join(".local/share/opencode"))
}
fn opencode_config_candidates(repo_root: &Path) -> TelemetryResult<Vec<PathBuf>> {
let home = home_dir()?;
Ok(vec![
repo_root.join("opencode.json"),
home.join(".config/opencode/opencode.json"),
])
}
fn find_limit_context(value: &Value) -> Option<u64> {
match value {
Value::Object(map) => {
if let Some(limit) = map.get("limit")
&& let Some(context) = number_key(limit, &["context"])
{
return Some(context);
}
for child in map.values() {
if let Some(context) = find_limit_context(child) {
return Some(context);
}
}
None
}
Value::Array(items) => items.iter().find_map(find_limit_context),
_ => None,
}
}
fn find_model_name(value: &Value) -> Option<String> {
match value {
Value::Object(map) => {
if let Some(Value::Object(models)) = map.get("models")
&& let Some(model_name) = models.keys().next()
{
return Some(model_name.clone());
}
for child in map.values() {
if let Some(model_name) = find_model_name(child) {
return Some(model_name);
}
}
None
}
Value::Array(items) => items.iter().find_map(find_model_name),
_ => None,
}
}