lifeloop/telemetry/
opencode.rs1use std::fs;
17use std::path::{Path, PathBuf};
18
19use serde_json::Value;
20
21use super::{
22 EnvAlias, PressureObservation, TelemetryError, TelemetryResult, TokenUsage, compute_pct,
23 general_context_window, general_host_model, home_dir, number_key, resolve_env_string,
24 resolve_env_u64,
25};
26
27const ADAPTER_ID: &str = "opencode";
28
29const OPENCODE_DB_PATH_ALIASES: &[EnvAlias] = &[EnvAlias {
30 lifeloop: "LIFELOOP_OPENCODE_DB_PATH",
31 ccd_compat: "CCD_OPENCODE_DB_PATH",
32}];
33
34const OPENCODE_DATA_DIR_ALIASES: &[EnvAlias] = &[
35 EnvAlias {
36 lifeloop: "LIFELOOP_OPENCODE_DATA_DIR",
37 ccd_compat: "CCD_OPENCODE_DATA_DIR",
38 },
39 EnvAlias {
40 lifeloop: "LIFELOOP_OPENCODE_DATA_DIR_NATIVE",
41 ccd_compat: "OPENCODE_DATA_DIR",
42 },
43];
44
45const OPENCODE_MODEL_ALIASES: &[EnvAlias] = &[EnvAlias {
46 lifeloop: "LIFELOOP_OPENCODE_MODEL",
47 ccd_compat: "CCD_OPENCODE_MODEL",
48}];
49
50const OPENCODE_CONTEXT_WINDOW_ALIASES: &[EnvAlias] = &[EnvAlias {
51 lifeloop: "LIFELOOP_OPENCODE_CONTEXT_WINDOW_TOKENS",
52 ccd_compat: "CCD_OPENCODE_CONTEXT_WINDOW_TOKENS",
53}];
54
55#[derive(Debug, Clone)]
58pub struct SessionMetrics {
59 pub prompt_tokens: u64,
60 pub completion_tokens: u64,
61 pub summary_message_id: Option<String>,
64}
65
66pub fn build_observation(
69 metrics: SessionMetrics,
70 observed_at_epoch_s: u64,
71 context_window_tokens: Option<u64>,
72 model_name: Option<String>,
73) -> PressureObservation {
74 let context_window = context_window_tokens
75 .or_else(|| resolve_env_u64(OPENCODE_CONTEXT_WINDOW_ALIASES))
76 .or_else(general_context_window);
77 let model_name = model_name
78 .or_else(|| resolve_env_string(OPENCODE_MODEL_ALIASES))
79 .or_else(general_host_model);
80
81 PressureObservation {
82 adapter_id: ADAPTER_ID.into(),
83 adapter_version: None,
84 observed_at_epoch_s,
85 model_name,
86 total_tokens: Some(metrics.prompt_tokens),
87 context_window_tokens: context_window,
88 context_used_pct: compute_pct(metrics.prompt_tokens, context_window),
89 compaction_signal: metrics
90 .summary_message_id
91 .as_deref()
92 .filter(|v| !v.is_empty())
93 .map(|_| true),
94 usage: TokenUsage {
95 input_tokens: metrics.prompt_tokens,
96 output_tokens: metrics.completion_tokens,
97 cache_creation_input_tokens: 0,
98 cache_read_input_tokens: 0,
99 blended_total_tokens: None,
100 },
101 }
102}
103
104pub fn read_context_window_from_config(bytes: &[u8]) -> Option<u64> {
107 let value: Value = serde_json::from_slice(bytes).ok()?;
108 find_limit_context(&value)
109}
110
111pub fn read_model_name_from_config(bytes: &[u8]) -> Option<String> {
114 let value: Value = serde_json::from_slice(bytes).ok()?;
115 find_model_name(&value)
116}
117
118pub fn resolve_context_window(repo_root: &Path) -> TelemetryResult<Option<u64>> {
121 if let Some(value) = resolve_env_u64(OPENCODE_CONTEXT_WINDOW_ALIASES) {
122 return Ok(Some(value));
123 }
124 for path in opencode_config_candidates(repo_root)? {
125 if !path.is_file() {
126 continue;
127 }
128 let bytes = fs::read(&path).map_err(TelemetryError::from)?;
129 if let Some(window) = read_context_window_from_config(&bytes) {
130 return Ok(Some(window));
131 }
132 }
133 Ok(general_context_window())
134}
135
136pub fn resolve_model_name(repo_root: &Path) -> TelemetryResult<Option<String>> {
139 for path in opencode_config_candidates(repo_root)? {
140 if !path.is_file() {
141 continue;
142 }
143 let bytes = fs::read(&path).map_err(TelemetryError::from)?;
144 if let Some(name) = read_model_name_from_config(&bytes) {
145 return Ok(Some(name));
146 }
147 }
148 Ok(resolve_env_string(OPENCODE_MODEL_ALIASES).or_else(general_host_model))
149}
150
151pub fn database_path_hint() -> Option<PathBuf> {
155 resolve_env_string(OPENCODE_DB_PATH_ALIASES).map(PathBuf::from)
156}
157
158pub fn data_dir_hint() -> TelemetryResult<PathBuf> {
161 if let Some(path) = resolve_env_string(OPENCODE_DATA_DIR_ALIASES) {
162 return Ok(PathBuf::from(path));
163 }
164 Ok(home_dir()?.join(".local/share/opencode"))
165}
166
167fn opencode_config_candidates(repo_root: &Path) -> TelemetryResult<Vec<PathBuf>> {
168 let home = home_dir()?;
169 Ok(vec![
170 repo_root.join("opencode.json"),
171 home.join(".config/opencode/opencode.json"),
172 ])
173}
174
175fn find_limit_context(value: &Value) -> Option<u64> {
178 match value {
179 Value::Object(map) => {
180 if let Some(limit) = map.get("limit")
181 && let Some(context) = number_key(limit, &["context"])
182 {
183 return Some(context);
184 }
185 for child in map.values() {
186 if let Some(context) = find_limit_context(child) {
187 return Some(context);
188 }
189 }
190 None
191 }
192 Value::Array(items) => items.iter().find_map(find_limit_context),
193 _ => None,
194 }
195}
196
197fn find_model_name(value: &Value) -> Option<String> {
198 match value {
199 Value::Object(map) => {
200 if let Some(Value::Object(models)) = map.get("models")
201 && let Some(model_name) = models.keys().next()
202 {
203 return Some(model_name.clone());
204 }
205 for child in map.values() {
206 if let Some(model_name) = find_model_name(child) {
207 return Some(model_name);
208 }
209 }
210 None
211 }
212 Value::Array(items) => items.iter().find_map(find_model_name),
213 _ => None,
214 }
215}