Skip to main content

oc_collect/
lib.rs

1use serde::Deserialize;
2use sqlx::SqlitePool;
3
4#[derive(Deserialize, Debug)]
5pub struct Model {
6    pub id: String,
7    #[serde(rename = "providerID")]
8    pub provider_id: String,
9    pub variant: Option<String>,
10}
11
12#[derive(Debug, sqlx::FromRow)]
13pub struct RowUsageSession {
14    pub id: String,
15    pub project_id: String,
16    pub slug: String,
17    pub title: String,
18    pub cost: f64,
19    pub tokens_input: i64,
20    pub tokens_output: i64,
21    pub tokens_reasoning: i64,
22    pub tokens_cache_read: i64,
23    pub tokens_cache_write: i64,
24    pub model: String,
25    pub time_created: i64,
26    pub time_updated: i64,
27}
28
29#[derive(Debug)]
30pub struct UsageSessionData {
31    pub session: RowUsageSession,
32    pub model: Model,
33}
34
35#[derive(Debug, Deserialize)]
36pub struct MessageCacheTokens {
37    pub write: i64,
38    pub read: i64,
39}
40
41#[derive(Debug, Deserialize)]
42pub struct MessageTokens {
43    // only in v1
44    pub total: Option<i64>,
45    pub input: i64,
46    pub output: i64,
47    pub reasoning: i64,
48    pub cache: MessageCacheTokens,
49}
50
51#[derive(Debug, Deserialize)]
52pub struct MessageData {
53    // v2 only fields schema
54    pub model: Option<Model>,
55    // v1 only fields schema
56    pub role: Option<String>,
57    #[serde(rename = "providerID")]
58    pub provider_id: Option<String>,
59    #[serde(rename = "modelID")]
60    pub model_id: Option<String>,
61    // can be None in some cases (v1 schema)
62    pub variant: Option<String>,
63    // shared
64    pub cost: Option<f64>,
65    pub tokens: Option<MessageTokens>,
66    pub agent: Option<String>,
67    pub finish: Option<String>,
68}
69
70#[derive(Debug, sqlx::FromRow)]
71pub struct RowSessionMessage {
72    pub id: String,
73    pub session_id: String,
74    pub role: Option<String>,
75    pub time_created: i64,
76    pub time_updated: i64,
77    pub data: String,
78}
79
80#[allow(dead_code)]
81#[derive(Debug, Deserialize)]
82pub struct SessionMessageItem {
83    pub id: String,
84    pub session_id: String,
85    pub cost: f64,
86    pub tokens_input: i64,
87    pub tokens_output: i64,
88    pub tokens_reasoning: i64,
89    pub tokens_cache_read: i64,
90    pub tokens_cache_write: i64,
91    pub time_created: i64,
92    pub time_updated: i64,
93}
94
95#[allow(dead_code)]
96#[derive(Debug)]
97pub struct SessionMessageData {
98    pub message: SessionMessageItem,
99    pub model: Model,
100}
101
102pub struct CollectClient {
103    pool: Option<SqlitePool>,
104}
105
106#[derive(Deserialize, sqlx::FromRow)]
107pub struct TableExists {
108    pub exists: i8,
109}
110
111impl CollectClient {
112    pub fn new() -> Self {
113        CollectClient { pool: None }
114    }
115
116    pub fn get_db_path() -> String {
117        let home = dirs::home_dir().unwrap();
118        let db_path = home
119            .join(".local")
120            .join("share")
121            .join("opencode")
122            .join("opencode.db");
123        db_path.to_str().unwrap().to_string()
124    }
125
126    async fn table_exists(&self, table_name: &str) -> anyhow::Result<bool> {
127        let pool = match &self.pool {
128            Some(pool) => pool,
129            None => {
130                anyhow::bail!("Database pool is not initialized. Call open_pool() first.");
131            }
132        };
133
134        let exists: bool = sqlx::query_scalar(
135            "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?)",
136        )
137        .bind(&table_name)
138        .fetch_one(pool)
139        .await?;
140
141        Ok(exists)
142    }
143
144    pub async fn open_pool(&mut self) -> anyhow::Result<()> {
145        let db_path = Self::get_db_path();
146        let pool = SqlitePool::connect(&db_path).await?;
147        self.pool = Some(pool);
148        Ok(())
149    }
150
151    pub async fn get_usage_sessions(
152        &self,
153        min_updated_at: Option<i64>,
154    ) -> anyhow::Result<Vec<UsageSessionData>> {
155        let time_updated = match min_updated_at {
156            Some(value) => value,
157            None => 0, // Default to 0 if no value is provided
158        };
159        let pool = match &self.pool {
160            Some(pool) => pool,
161            None => {
162                anyhow::bail!("Database pool is not initialized. Call open_pool() first.");
163            }
164        };
165
166        let use_v2_table = self.table_exists("session_v2").await?;
167
168        let sessions = if use_v2_table {
169            sqlx::query_as::<_, RowUsageSession>(
170                r#"
171        SELECT
172            id,
173            project_id,
174            slug,
175            title,
176            cost,
177            tokens_input,
178            tokens_output,
179            tokens_reasoning,
180            tokens_cache_read,
181            tokens_cache_write,
182            model,
183            time_created,
184            time_updated
185        FROM session_v2
186        WHERE time_updated > ?1
187        ORDER BY time_created DESC
188        "#,
189            )
190        } else {
191            sqlx::query_as::<_, RowUsageSession>(
192                r#"
193        SELECT
194            id,
195            project_id,
196            slug,
197            title,
198            cost,
199            tokens_input,
200            tokens_output,
201            tokens_reasoning,
202            tokens_cache_read,
203            tokens_cache_write,
204            model,
205            time_created,
206            time_updated
207        FROM session
208        WHERE time_updated > ?1
209        ORDER BY time_created DESC
210        "#,
211            )
212        }
213        .bind(time_updated)
214        .fetch_all(pool)
215        .await?;
216
217        let extended_sessions = sessions
218            .into_iter()
219            .map(|session| {
220                let model_result = serde_json::from_str::<Model>(&session.model);
221                let model = match model_result {
222                    Ok(model) => model,
223                    Err(e) => {
224                        eprintln!(
225                            "Failed to deserialize model for session {}: {}",
226                            session.id, e
227                        );
228                        Model {
229                            id: "unknown".to_string(),
230                            provider_id: "unknown".to_string(),
231                            variant: None,
232                        }
233                    }
234                };
235
236                UsageSessionData { session, model }
237            })
238            .collect();
239
240        Ok(extended_sessions)
241    }
242
243    pub async fn get_session_messages(
244        &self,
245        min_updated_at: Option<i64>,
246    ) -> anyhow::Result<Vec<SessionMessageData>> {
247        let time_updated = match min_updated_at {
248            Some(value) => value,
249            None => 0, // Default to 0 if no value is provided
250        };
251        let pool = match &self.pool {
252            Some(pool) => pool,
253            None => {
254                anyhow::bail!("Database pool is not initialized. Call open_pool() first.");
255            }
256        };
257
258        let use_v2_table = self.table_exists("session_message").await?;
259        let messages = if use_v2_table {
260            sqlx::query_as::<_, RowSessionMessage>(
261                r#"
262        SELECT
263            id,
264            session_id,
265            type as role,
266            time_created,
267            time_updated,
268            data
269        FROM session_message
270        WHERE time_updated > ?1
271        ORDER BY time_created DESC
272        "#,
273            )
274        } else {
275            sqlx::query_as::<_, RowSessionMessage>(
276                r#"
277        SELECT
278            id,
279            session_id,
280            NULL as role,
281            time_created,
282            time_updated,
283            data
284        FROM message
285        WHERE time_updated > ?1
286        ORDER BY time_created DESC
287        "#,
288            )
289        }
290        .bind(time_updated)
291        .fetch_all(pool)
292        .await?;
293
294        let extended_messages: Vec<SessionMessageData> = messages
295            .into_iter()
296            .filter_map(|message| {
297                let message_result = serde_json::from_str::<MessageData>(&message.data);
298                let message_data = match message_result {
299                    Ok(message_data) => message_data,
300                    Err(e) => {
301                        eprintln!(
302                            "Failed to deserialize data for message {}: {}",
303                            message.id, e
304                        );
305                        return None;
306                    }
307                };
308
309                let role = match message.role {
310                    Some(role) => Some(role),
311                    None => message_data.role,
312                };
313                if role.is_none() || role.unwrap() != "assistant" {
314                    return None;
315                }
316
317                if message_data.finish.is_some() && message_data.finish.unwrap() == "error" {
318                    return None;
319                }
320
321                let model = if let Some(model) = message_data.model {
322                    model
323                } else {
324                    Model {
325                        id: message_data.model_id.unwrap_or("unknown".to_owned()),
326                        provider_id: message_data.provider_id.unwrap_or("unknown".to_owned()),
327                        variant: message_data.variant,
328                    }
329                };
330                if message_data.tokens.is_none() {
331                    eprintln!("Tokens are missing for message {}", message.id);
332                    return None;
333                }
334
335                let tokens = message_data.tokens.unwrap();
336
337                let message_item = SessionMessageItem {
338                    id: message.id,
339                    session_id: message.session_id,
340                    cost: message_data.cost.unwrap_or(0.0),
341                    tokens_input: tokens.input,
342                    tokens_output: tokens.output,
343                    tokens_reasoning: tokens.reasoning,
344                    tokens_cache_read: tokens.cache.read,
345                    tokens_cache_write: tokens.cache.write,
346                    time_created: message.time_created,
347                    time_updated: message.time_updated,
348                };
349
350                Some(SessionMessageData {
351                    message: message_item,
352                    model,
353                })
354            })
355            .collect();
356
357        Ok(extended_messages)
358    }
359}