From 99db72846d5b89ae1a009667b4f380f46b405ee6 Mon Sep 17 00:00:00 2001 From: Jacky Lam Date: Wed, 12 Aug 2026 11:18:39 +0800 Subject: [PATCH] fix: use OpenCode Go account quotas --- docs/providers/opencode.md | 13 +- src-tauri/src/providers/opencode/client.rs | 64 ++++ src-tauri/src/providers/opencode/database.rs | 65 +--- src-tauri/src/providers/opencode/mapper.rs | 81 +++++ src-tauri/src/providers/opencode/mod.rs | 84 ++++-- src-tauri/src/providers/opencode/record.rs | 2 - src-tauri/src/providers/opencode/scanner.rs | 40 +-- src-tauri/src/providers/opencode/tests.rs | 98 +----- src-tauri/src/providers/opencode/windows.rs | 302 ------------------- 9 files changed, 223 insertions(+), 526 deletions(-) create mode 100644 src-tauri/src/providers/opencode/client.rs create mode 100644 src-tauri/src/providers/opencode/mapper.rs delete mode 100644 src-tauri/src/providers/opencode/windows.rs diff --git a/docs/providers/opencode.md b/docs/providers/opencode.md index c66e1b4..10ec52f 100644 --- a/docs/providers/opencode.md +++ b/docs/providers/opencode.md @@ -6,19 +6,18 @@ OpenQuota combines OpenCode Go quota information with usage recorded by local Op | Metric | Meaning | | -------------------------------- | ------------------------------------------------- | -| Session | OpenCode Go session allowance remaining | -| Weekly | OpenCode Go weekly allowance remaining | -| Monthly | OpenCode Go monthly spend allowance remaining | +| Session | OpenCode Go rolling-window usage | +| Weekly | OpenCode Go weekly usage | +| Monthly | OpenCode Go monthly usage | | Today / Yesterday / Last 30 Days | Local hosted usage and spend recorded by OpenCode | | Usage Trend | Recent local usage over time | Go quota rows appear when a compatible OpenCode Go login is available. Local history can still be shown when OpenCode has been used without that plan. -The Go meters compare usage recorded on this computer with the plan caps. Usage from another device -or a session that has not yet been written locally can make them lower than the account-wide total. -The displayed costs come from the values recorded by OpenCode rather than being estimated by -OpenQuota. +The Go meters come from OpenCode's account usage endpoint, so they include usage from all devices +and reflect the limits enforced by OpenCode. Local usage history remains separate and is read from +the OpenCode data directory. ## Sign-in and local data diff --git a/src-tauri/src/providers/opencode/client.rs b/src-tauri/src/providers/opencode/client.rs new file mode 100644 index 0000000..f6bd1cd --- /dev/null +++ b/src-tauri/src/providers/opencode/client.rs @@ -0,0 +1,64 @@ +use std::time::Duration; + +use reqwest::{blocking::Client, StatusCode}; +use serde_json::Value; + +use super::OpenCodeError; + +const GO_USAGE_URL: &str = "https://opencode.ai/zen/go/v1/usage"; + +#[derive(Debug)] +pub(super) struct UsageResponse { + pub(super) status: StatusCode, + pub(super) body: Value, +} + +pub(super) struct OpenCodeClient { + client: Client, + usage_url: String, +} + +impl OpenCodeClient { + pub(super) fn new() -> Result { + Self::with_endpoint(GO_USAGE_URL, Duration::from_secs(15)) + } + + fn with_endpoint(usage_url: &str, timeout: Duration) -> Result { + Ok(Self { + client: Client::builder() + .connect_timeout(Duration::from_secs(8)) + .timeout(timeout) + .user_agent(concat!("OpenQuota/", env!("CARGO_PKG_VERSION"))) + .build() + .map_err(|_| OpenCodeError::ConnectionFailed)?, + usage_url: usage_url.into(), + }) + } + + pub(super) fn fetch_go_usage(&self, api_key: &str) -> Result { + let started = std::time::Instant::now(); + let response = self + .client + .get(&self.usage_url) + .bearer_auth(api_key) + .header("Accept", "application/json") + .send() + .map_err(|_| { + crate::app_warn!("http", "opencode go usage request failed (transport)"); + OpenCodeError::ConnectionFailed + })?; + let status = response.status(); + crate::app_debug!( + "http", + "opencode go usage HTTP {} ({}ms)", + status.as_u16(), + started.elapsed().as_millis() + ); + let body = response + .text() + .ok() + .and_then(|text| serde_json::from_str(&text).ok()) + .unwrap_or(Value::Null); + Ok(UsageResponse { status, body }) + } +} diff --git a/src-tauri/src/providers/opencode/database.rs b/src-tauri/src/providers/opencode/database.rs index 6d46bf0..df616fe 100644 --- a/src-tauri/src/providers/opencode/database.rs +++ b/src-tauri/src/providers/opencode/database.rs @@ -5,15 +5,13 @@ use std::{ time::Duration, }; -use chrono::{DateTime, Utc}; use rusqlite::{types::ValueRef, Connection, OpenFlags, Row}; use serde_json::Value; use crate::pricing::ModelPricing; use super::record::{ - parse_message, parse_part, provider_id, timestamp_from_number, timestamp_from_value, - ParsedMessage, ParsedPart, UsageRecord, EPOCH_MILLISECONDS_THRESHOLD, GO_PROVIDER_ID, + parse_message, parse_part, ParsedMessage, ParsedPart, UsageRecord, EPOCH_MILLISECONDS_THRESHOLD, }; const PROVIDER_JSON: &str = "COALESCE(\ @@ -25,7 +23,6 @@ const PROVIDER_JSON: &str = "COALESCE(\ #[derive(Debug, Default)] pub(crate) struct DatabaseUsage { pub(crate) records: Vec, - pub(crate) go_anchor: Option>, } pub(crate) enum DatabaseRead { @@ -51,7 +48,6 @@ pub(crate) fn read_database( let schema = inspect_schema(&connection)?; let mut messages = load_messages(&connection, &schema, cutoff_ms)?; let parts = load_parts(&connection, &schema, cutoff_ms, &messages)?; - let go_anchor = load_go_anchor(&connection, &schema)?; let mut records = Vec::with_capacity(messages.len()); for message in messages.drain(..) { @@ -61,7 +57,7 @@ pub(crate) fn read_database( .unwrap_or(&[]); records.push(message.into_usage(message_parts, pricing)); } - Ok(DatabaseRead::Usable(DatabaseUsage { records, go_anchor })) + Ok(DatabaseRead::Usable(DatabaseUsage { records })) } pub(crate) fn has_hosted_usage(path: &Path) -> Result { @@ -307,63 +303,6 @@ fn timestamp_filter(column: &str) -> String { format!(" WHERE ({column} >= ?1 OR ({column} >= ?2 AND {column} < ?3))") } -fn load_go_anchor( - connection: &Connection, - schema: &DatabaseSchema, -) -> Result>, ()> { - let join = schema.session_join_sql(); - let cost = hosted_cost_sql(schema.part.is_some()); - if schema.message_time_created { - let sql = format!( - "SELECT m.time_created FROM message m{join} \ - WHERE json_valid(m.data) \ - AND json_extract(m.data,'$.role') = 'assistant' \ - AND {PROVIDER_JSON} = 'opencode-go' \ - AND {cost}" - ); - let mut statement = connection.prepare(&sql).map_err(|_| ())?; - let mut rows = statement.query([]).map_err(|_| ())?; - let mut anchor: Option> = None; - while let Some(row) = rows.next().map_err(|_| ())? { - if let Some(timestamp) = row_i64(row, 0).and_then(timestamp_from_number) { - anchor = Some(anchor.map_or(timestamp, |current| current.min(timestamp))); - } - } - return Ok(anchor); - } - - let sql = format!("SELECT NULL, m.data, {cost} FROM message m{join} WHERE json_valid(m.data)"); - let mut statement = connection.prepare(&sql).map_err(|_| ())?; - let mut rows = statement.query([]).map_err(|_| ())?; - let mut anchor: Option> = None; - while let Some(row) = rows.next().map_err(|_| ())? { - let column_timestamp = row_i64(row, 0); - let Some(data) = row_text(row, 1) else { - continue; - }; - let Ok(value) = serde_json::from_str::(&data) else { - continue; - }; - if value.get("role").and_then(Value::as_str) != Some("assistant") - || provider_id(&value).as_deref() != Some(GO_PROVIDER_ID) - || !row.get::<_, bool>(2).unwrap_or(false) - { - continue; - } - let timestamp = column_timestamp - .and_then(timestamp_from_number) - .or_else(|| { - value - .pointer("/time/created") - .and_then(timestamp_from_value) - }); - if let Some(timestamp) = timestamp { - anchor = Some(anchor.map_or(timestamp, |current| current.min(timestamp))); - } - } - Ok(anchor) -} - fn row_text(row: &Row<'_>, index: usize) -> Option { match row.get_ref(index).ok()? { ValueRef::Text(value) | ValueRef::Blob(value) => { diff --git a/src-tauri/src/providers/opencode/mapper.rs b/src-tauri/src/providers/opencode/mapper.rs new file mode 100644 index 0000000..df0cb7e --- /dev/null +++ b/src-tauri/src/providers/opencode/mapper.rs @@ -0,0 +1,81 @@ +use chrono::{DateTime, Utc}; +use serde_json::Value; + +use crate::models::{QuotaFormat, QuotaWindow}; + +use super::{client::UsageResponse, OpenCodeError}; + +pub(super) fn map_go_usage(response: UsageResponse) -> Result, OpenCodeError> { + match response.status.as_u16() { + 200..=299 => {} + 401 | 403 => return Err(OpenCodeError::InvalidAuth), + status => return Err(OpenCodeError::RequestFailed(status)), + } + let usage = response + .body + .get("usage") + .and_then(Value::as_object) + .ok_or(OpenCodeError::InvalidResponse)?; + [ + quota(usage.get("rolling"), "session", "Session"), + quota(usage.get("weekly"), "weekly", "Weekly"), + quota(usage.get("monthly"), "monthly", "Monthly"), + ] + .into_iter() + .collect() +} + +fn quota(value: Option<&Value>, id: &str, label: &str) -> Result { + let value = value + .and_then(Value::as_object) + .ok_or(OpenCodeError::InvalidResponse)?; + let used_percent = value + .get("percent") + .and_then(Value::as_f64) + .filter(|value| value.is_finite()) + .ok_or(OpenCodeError::InvalidResponse)? + .clamp(0.0, 100.0); + let resets_at = value + .get("resetsAt") + .and_then(Value::as_str) + .and_then(|value| DateTime::parse_from_rfc3339(value).ok()) + .map(|value| value.with_timezone(&Utc)); + Ok(QuotaWindow { + id: id.into(), + label: label.into(), + used_percent, + resets_at, + period_seconds: 0, + format: QuotaFormat::Percent, + used_value: None, + limit_value: None, + unit: None, + estimated: false, + source_note: None, + }) +} + +#[cfg(test)] +mod tests { + use reqwest::StatusCode; + use serde_json::json; + + use super::{map_go_usage, UsageResponse}; + + #[test] + fn maps_authoritative_go_usage_windows() { + let response = UsageResponse { + status: StatusCode::OK, + body: json!({"usage": { + "rolling": {"percent": 31, "resetsAt": "2026-08-12T12:00:00Z", "status": "active"}, + "weekly": {"percent": 100, "resetsAt": "2026-08-17T00:00:00Z", "status": "exhausted"}, + "monthly": {"percent": 72, "resetsAt": "2026-09-05T00:00:00Z", "status": "active"} + }}), + }; + let quotas = map_go_usage(response).unwrap(); + assert_eq!(quotas.len(), 3); + assert_eq!(quotas[0].id, "session"); + assert_eq!(quotas[1].used_percent, 100.0); + assert!(!quotas.iter().any(|quota| quota.estimated)); + } +} diff --git a/src-tauri/src/providers/opencode/mod.rs b/src-tauri/src/providers/opencode/mod.rs index f745e49..67ed23b 100644 --- a/src-tauri/src/providers/opencode/mod.rs +++ b/src-tauri/src/providers/opencode/mod.rs @@ -1,8 +1,9 @@ +mod client; mod database; +mod mapper; mod paths; mod record; mod scanner; -mod windows; use std::sync::Arc; @@ -18,9 +19,10 @@ use crate::{ }; use self::{ + client::OpenCodeClient, + mapper::map_go_usage, paths::OpenCodePaths, scanner::{OpenCodeUsageScanner, USAGE_SOURCE_NOTE}, - windows::OpenCodeWindows, }; use super::{ProviderError, UsageProvider}; @@ -100,16 +102,31 @@ pub(crate) enum OpenCodeError { DataDirectoryUnreadable, #[error("OpenCode local usage data is temporarily unavailable.")] DatabaseUnreadable, + #[error("OpenCode Go login data is invalid or expired. Sign in to OpenCode Go again.")] + InvalidAuth, + #[error("Could not reach OpenCode Go. Check your internet connection.")] + ConnectionFailed, + #[error("OpenCode Go returned an invalid usage response.")] + InvalidResponse, + #[error("OpenCode Go usage request failed (HTTP {0}).")] + RequestFailed(u16), } impl From for ProviderError { fn from(error: OpenCodeError) -> Self { let kind = match error { - OpenCodeError::NotDetected => ProviderErrorKind::Authentication, + OpenCodeError::NotDetected | OpenCodeError::InvalidAuth => { + ProviderErrorKind::Authentication + } OpenCodeError::CredentialsUnreadable => ProviderErrorKind::CredentialStorage, OpenCodeError::DataDirectoryUnreadable | OpenCodeError::DatabaseUnreadable => { ProviderErrorKind::LocalData } + OpenCodeError::ConnectionFailed => ProviderErrorKind::Network, + OpenCodeError::RequestFailed(429) => ProviderErrorKind::RateLimited, + OpenCodeError::InvalidResponse | OpenCodeError::RequestFailed(_) => { + ProviderErrorKind::InvalidResponse + } }; ProviderError::new(kind, error.to_string()) } @@ -118,6 +135,7 @@ impl From for ProviderError { pub struct OpenCodeProvider { paths: OpenCodePaths, scanner: OpenCodeUsageScanner, + client: OpenCodeClient, pricing: Arc, now: Arc DateTime + Send + Sync>, } @@ -128,6 +146,7 @@ impl OpenCodeProvider { Self { scanner: OpenCodeUsageScanner::new(paths.clone()), paths, + client: OpenCodeClient::new().expect("OpenCode Go client configuration is valid"), pricing, now: Arc::new(Utc::now), } @@ -142,6 +161,7 @@ impl OpenCodeProvider { Self { scanner: OpenCodeUsageScanner::new(paths.clone()), paths, + client: OpenCodeClient::new().expect("OpenCode Go client configuration is valid"), pricing, now: Arc::new(move || now), } @@ -149,27 +169,46 @@ impl OpenCodeProvider { fn refresh_snapshot(&self) -> Result { let now = (self.now)(); - let (has_go_key, go_key_error) = match self.paths.go_api_key() { - Ok(Some(_)) => (true, None), - Ok(None) => (false, None), - Err(error) => (false, Some(error)), + let (go_api_key, go_key_error) = match self.paths.go_api_key() { + Ok(key) => (key, None), + Err(error) => (None, Some(error)), }; + let go_usage = go_api_key + .as_deref() + .map(|key| self.client.fetch_go_usage(key).and_then(map_go_usage)) + .transpose(); let pricing = self.pricing.current(); - let scan = self.scanner.scan(now, has_go_key, &pricing)?; + let scan = self.scanner.scan(now, &pricing); + + let scan = match scan { + Ok(scan) => scan, + Err(error) => match go_usage { + Ok(Some(quotas)) => { + return Ok(snapshot( + Some("Go".into()), + quotas, + UsageHistory::default(), + vec!["OpenCode local usage data is temporarily unavailable.".into()], + now, + )); + } + _ => return Err(error), + }, + }; let Some(scan) = scan else { - if has_go_key { - return Ok(snapshot( + return match go_usage { + Ok(Some(quotas)) => Ok(snapshot( Some("Go".into()), - OpenCodeWindows::compute(&[], None, now).quotas(), + quotas, UsageHistory::default(), Vec::new(), now, - )); - } - return Err(go_key_error.unwrap_or(OpenCodeError::NotDetected)); + )), + Ok(None) => Err(go_key_error.unwrap_or(OpenCodeError::NotDetected)), + Err(error) => Err(error), + }; }; - let mut warnings = scan.warnings; if go_key_error.is_some() { warnings.push( @@ -177,10 +216,17 @@ impl OpenCodeProvider { .into(), ); } - let (plan, quotas) = scan.go_windows.map_or_else( - || (None, Vec::new()), - |windows| (Some("Go".into()), windows.quotas()), - ); + let (plan, quotas) = match go_usage { + Ok(Some(quotas)) => (Some("Go".into()), quotas), + Ok(None) => (None, Vec::new()), + Err(_) => { + warnings.push( + "OpenCode Go quota data is temporarily unavailable; local usage is still shown." + .into(), + ); + (None, Vec::new()) + } + }; Ok(snapshot(plan, quotas, scan.usage, warnings, now)) } } diff --git a/src-tauri/src/providers/opencode/record.rs b/src-tauri/src/providers/opencode/record.rs index 890af97..1b26436 100644 --- a/src-tauri/src/providers/opencode/record.rs +++ b/src-tauri/src/providers/opencode/record.rs @@ -18,7 +18,6 @@ pub(crate) enum CostProvenance { pub(crate) struct UsageRecord { pub(crate) key: (String, String), pub(crate) timestamp: DateTime, - pub(crate) provider_id: String, pub(crate) model: String, pub(crate) tokens: u64, pub(crate) cost: Option, @@ -65,7 +64,6 @@ impl ParsedMessage { UsageRecord { key: (self.session_id, self.message_id), timestamp: self.timestamp, - provider_id: self.provider_id, model: self.model, tokens: tokens.total, cost, diff --git a/src-tauri/src/providers/opencode/scanner.rs b/src-tauri/src/providers/opencode/scanner.rs index 4acc6e7..e24dfe0 100644 --- a/src-tauri/src/providers/opencode/scanner.rs +++ b/src-tauri/src/providers/opencode/scanner.rs @@ -9,8 +9,7 @@ use crate::{ use super::{ database::{has_hosted_usage, read_database, DatabaseRead}, paths::OpenCodePaths, - record::{CostProvenance, UsageRecord, GO_PROVIDER_ID}, - windows::OpenCodeWindows, + record::{CostProvenance, UsageRecord}, OpenCodeError, }; @@ -21,7 +20,6 @@ pub(crate) const USAGE_SOURCE_NOTE: &str = #[derive(Debug)] pub(crate) struct OpenCodeUsageScan { pub(crate) usage: UsageHistory, - pub(crate) go_windows: Option, pub(crate) warnings: Vec, } @@ -48,30 +46,27 @@ impl OpenCodeUsageScanner { pub(crate) fn scan( &self, now: DateTime, - has_go_key: bool, pricing: &ModelPricing, ) -> Result, OpenCodeError> { let paths = self.paths.database_files()?; - self.scan_paths(paths, now, has_go_key, pricing) + self.scan_paths(paths, now, pricing) } pub(crate) fn scan_paths( &self, mut paths: Vec, now: DateTime, - has_go_key: bool, pricing: &ModelPricing, ) -> Result, OpenCodeError> { paths.sort(); paths.dedup(); - self.scan_sorted_paths(paths, now, has_go_key, pricing) + self.scan_sorted_paths(paths, now, pricing) } fn scan_sorted_paths( &self, paths: Vec, now: DateTime, - has_go_key: bool, pricing: &ModelPricing, ) -> Result, OpenCodeError> { if paths.is_empty() { @@ -80,7 +75,6 @@ impl OpenCodeUsageScanner { let cutoff_ms = (now - chrono::Duration::days(SCAN_DAYS)).timestamp_millis(); let mut records = Vec::new(); - let mut go_anchor = None; let mut usable_databases = 0_usize; let mut failed_databases = 0_usize; for path in paths { @@ -89,12 +83,6 @@ impl OpenCodeUsageScanner { Ok(DatabaseRead::Usable(database)) => { usable_databases += 1; records.extend(database.records); - if let Some(candidate) = database.go_anchor { - go_anchor = - Some(go_anchor.map_or(candidate, |current: DateTime| { - current.min(candidate) - })); - } } Err(()) => failed_databases += 1, } @@ -108,26 +96,6 @@ impl OpenCodeUsageScanner { } let records = deduplicate(records); - let current_records = records - .iter() - .filter(|record| record.timestamp <= now) - .collect::>(); - let go_activity = current_records.iter().any(|record| { - record.provider_id == GO_PROVIDER_ID - && record.cost_provenance == CostProvenance::Exact - && record.cost.is_some() - }); - let go_costs = current_records - .iter() - .filter(|record| { - record.provider_id == GO_PROVIDER_ID - && record.cost_provenance == CostProvenance::Exact - }) - .filter_map(|record| record.cost.map(|cost| (record.timestamp, cost))) - .collect::>(); - let go_windows = (has_go_key || go_activity) - .then(|| OpenCodeWindows::compute(&go_costs, go_anchor, now)); - let mut warnings = Vec::new(); if failed_databases > 0 { crate::app_warn!( @@ -141,7 +109,6 @@ impl OpenCodeUsageScanner { Ok(Some(OpenCodeUsageScan { usage: aggregate_history(&records, now), - go_windows, warnings, })) } @@ -247,7 +214,6 @@ mod unit_tests { UsageRecord { key: ("session".into(), "message".into()), timestamp: Utc.with_ymd_and_hms(2026, 7, 18, 10, 0, 0).unwrap(), - provider_id: "opencode".into(), model: "model".into(), tokens, cost, diff --git a/src-tauri/src/providers/opencode/tests.rs b/src-tauri/src/providers/opencode/tests.rs index 4c54ae2..7401d4f 100644 --- a/src-tauri/src/providers/opencode/tests.rs +++ b/src-tauri/src/providers/opencode/tests.rs @@ -119,7 +119,7 @@ fn exact_message(provider: &str, model: &str, cost: f64, input: u64, output: u64 fn scan(paths: Vec) -> super::scanner::OpenCodeUsageScan { OpenCodeUsageScanner::for_paths(paths.clone()) - .scan_paths(paths, now(), false, &pricing()) + .scan_paths(paths, now(), &pricing()) .unwrap() .unwrap() } @@ -195,42 +195,6 @@ fn multiple_databases_are_merged_and_duplicate_messages_count_once() { assert_eq!(today.tokens, 500); assert_eq!(today.estimated_cost_usd, Some(5.0)); assert!(!today.cost_estimated); - assert_eq!(result.go_windows.unwrap().session_spend, 2.0); -} - -#[test] -fn costless_old_go_rows_do_not_shift_the_monthly_anchor() { - let directory = tempdir().unwrap(); - let path = directory.path().join("opencode.db"); - let connection = create_database(&path, false); - insert_message( - &connection, - "old-session", - "old-message", - Utc.with_ymd_and_hms(2026, 1, 31, 9, 0, 0) - .unwrap() - .timestamp_millis(), - r#"{ - "role":"assistant", - "providerID":"opencode-go", - "modelID":"priced-model", - "tokens":{"total":100,"input":100,"output":0} - }"#, - ); - insert_message( - &connection, - "current-session", - "current-message", - timestamp(), - &exact_message("opencode-go", "priced-model", 2.0, 200, 0), - ); - drop(connection); - - let windows = scan(vec![path]).go_windows.unwrap(); - assert_eq!( - windows.monthly_resets_at, - Utc.with_ymd_and_hms(2026, 8, 18, 10, 0, 0).unwrap() - ); } #[test] @@ -370,10 +334,6 @@ fn epoch_second_rows_and_invalid_message_cost_fall_back_to_valid_parts() { assert_eq!(today.tokens, 125); assert_eq!(today.estimated_cost_usd, Some(1.5)); assert!(!today.cost_estimated); - assert_eq!( - result.go_windows.unwrap().monthly_resets_at, - Utc.with_ymd_and_hms(2026, 8, 18, 10, 0, 0).unwrap() - ); } #[test] @@ -406,36 +366,6 @@ fn missing_stored_cost_uses_pricing_and_marks_the_period_estimated() { assert!(today.cost_estimated); } -#[test] -fn estimated_go_spend_does_not_invent_subscription_caps() { - let directory = tempdir().unwrap(); - let path = directory.path().join("opencode.db"); - let connection = create_database(&path, false); - insert_message( - &connection, - "session-1", - "message-1", - timestamp(), - r#"{ - "role":"assistant", - "providerID":"opencode-go", - "modelID":"priced-model", - "tokens":{ - "total":1000000, - "input":1000000, - "output":0 - } - }"#, - ); - drop(connection); - - let result = scan(vec![path]); - let today = result.usage.today.unwrap(); - assert_eq!(today.estimated_cost_usd, Some(1.0)); - assert!(today.cost_estimated); - assert!(result.go_windows.is_none()); -} - #[test] fn exact_zero_cost_is_usage_but_unknown_cost_stays_visible_as_incomplete() { let directory = tempdir().unwrap(); @@ -562,7 +492,7 @@ fn all_present_unusable_databases_return_a_safe_typed_error() { let path = directory.path().join("opencode.db"); fs::write(&path, b"private-content-not-a-database").unwrap(); let error = OpenCodeUsageScanner::for_paths(vec![path.clone()]) - .scan_paths(vec![path.clone()], now(), false, &pricing()) + .scan_paths(vec![path.clone()], now(), &pricing()) .unwrap_err(); assert_eq!(error, OpenCodeError::DatabaseUnreadable); assert!(!error.to_string().contains(path.to_string_lossy().as_ref())); @@ -607,30 +537,6 @@ fn local_detection_requires_a_key_or_a_readable_hosted_usage_row() { assert!(provider.has_local_credentials()); } -#[test] -fn go_key_without_database_shows_zero_estimated_caps() { - let directory = tempdir().unwrap(); - fs::write( - directory.path().join("auth.json"), - r#"{"opencode-go":{"type":"api","key":"secret-key"}}"#, - ) - .unwrap(); - let provider = OpenCodeProvider::with_dependencies( - OpenCodePaths::for_data_directory(directory.path().to_path_buf()), - pricing_store(directory.path()), - now(), - ); - let snapshot = provider.refresh().unwrap(); - assert_eq!(snapshot.plan.as_deref(), Some("Go")); - assert_eq!(snapshot.quotas.len(), 3); - assert!(snapshot.quotas.iter().all(|quota| { - quota.used_value == Some(0.0) - && quota.estimated - && quota.source_note.is_some() - && quota.unit.as_deref() == Some("usd") - })); -} - #[test] fn malformed_auth_does_not_blank_valid_database_usage() { let directory = tempdir().unwrap(); diff --git a/src-tauri/src/providers/opencode/windows.rs b/src-tauri/src/providers/opencode/windows.rs deleted file mode 100644 index acc5658..0000000 --- a/src-tauri/src/providers/opencode/windows.rs +++ /dev/null @@ -1,302 +0,0 @@ -use chrono::{DateTime, Datelike, Duration, NaiveDate, TimeZone, Timelike, Utc}; - -use crate::models::{QuotaFormat, QuotaWindow}; - -pub(crate) const SESSION_CAP_USD: f64 = 12.0; -pub(crate) const WEEKLY_CAP_USD: f64 = 30.0; -pub(crate) const MONTHLY_CAP_USD: f64 = 60.0; -pub(crate) const SESSION_SECONDS: u64 = 5 * 60 * 60; -pub(crate) const WEEK_SECONDS: u64 = 7 * 24 * 60 * 60; -pub(crate) const QUOTA_SOURCE_NOTE: &str = - "Estimated from OpenCode Go activity recorded on this device; activity elsewhere may be missing."; - -#[derive(Debug, Clone, PartialEq)] -pub(crate) struct OpenCodeWindows { - pub(crate) session_spend: f64, - pub(crate) session_resets_at: DateTime, - pub(crate) weekly_spend: f64, - pub(crate) weekly_resets_at: DateTime, - pub(crate) monthly_spend: f64, - pub(crate) monthly_resets_at: DateTime, - pub(crate) monthly_period_seconds: u64, -} - -impl OpenCodeWindows { - pub(crate) fn compute( - costs: &[(DateTime, f64)], - anchor: Option>, - now: DateTime, - ) -> Self { - let session_start = now - Duration::seconds(SESSION_SECONDS as i64); - let session_rows = costs - .iter() - .filter(|(timestamp, _)| *timestamp >= session_start && *timestamp < now) - .collect::>(); - let session_spend = rounded_cost(session_rows.iter().map(|(_, cost)| *cost).sum()); - let session_resets_at = session_rows - .iter() - .map(|(timestamp, _)| *timestamp) - .min() - .unwrap_or(now) - + Duration::seconds(SESSION_SECONDS as i64); - - let week_start_date = now - .date_naive() - .checked_sub_days(chrono::Days::new(u64::from( - now.weekday().num_days_from_monday(), - ))) - .unwrap_or(now.date_naive()); - let week_start = Utc.from_utc_datetime( - &week_start_date - .and_hms_opt(0, 0, 0) - .expect("midnight is valid"), - ); - let weekly_resets_at = week_start + Duration::seconds(WEEK_SECONDS as i64); - let weekly_spend = rounded_cost(sum_range(costs, week_start, weekly_resets_at)); - - let (month_start, monthly_resets_at) = month_bounds(now, anchor); - let monthly_spend = rounded_cost(sum_range(costs, month_start, monthly_resets_at)); - let monthly_period_seconds = monthly_resets_at - .signed_duration_since(month_start) - .num_seconds() - .max(0) as u64; - - Self { - session_spend, - session_resets_at, - weekly_spend, - weekly_resets_at, - monthly_spend, - monthly_resets_at, - monthly_period_seconds, - } - } - - pub(crate) fn quotas(&self) -> Vec { - vec![ - quota( - "session", - "Session", - self.session_spend, - SESSION_CAP_USD, - self.session_resets_at, - SESSION_SECONDS, - ), - quota( - "weekly", - "Weekly", - self.weekly_spend, - WEEKLY_CAP_USD, - self.weekly_resets_at, - WEEK_SECONDS, - ), - quota( - "monthly", - "Monthly", - self.monthly_spend, - MONTHLY_CAP_USD, - self.monthly_resets_at, - self.monthly_period_seconds, - ), - ] - } -} - -fn quota( - id: &str, - label: &str, - used: f64, - limit: f64, - resets_at: DateTime, - period_seconds: u64, -) -> QuotaWindow { - QuotaWindow { - id: id.into(), - label: label.into(), - used_percent: (used / limit * 100.0).clamp(0.0, 100.0), - resets_at: Some(resets_at), - period_seconds, - format: QuotaFormat::Dollars, - used_value: Some(used), - limit_value: Some(limit), - unit: Some("usd".into()), - estimated: true, - source_note: Some(QUOTA_SOURCE_NOTE.into()), - } -} - -fn sum_range(costs: &[(DateTime, f64)], start: DateTime, end: DateTime) -> f64 { - costs - .iter() - .filter(|(timestamp, _)| *timestamp >= start && *timestamp < end) - .map(|(_, cost)| *cost) - .sum() -} - -fn rounded_cost(cost: f64) -> f64 { - (cost * 10_000.0).round() / 10_000.0 -} - -fn month_bounds( - now: DateTime, - anchor: Option>, -) -> (DateTime, DateTime) { - let Some(anchor) = anchor.filter(|anchor| *anchor <= now) else { - let start = utc_date(now.year(), now.month(), 1, 0, 0, 0, 0); - let (next_year, next_month) = shift_month(now.year(), now.month(), 1); - return (start, utc_date(next_year, next_month, 1, 0, 0, 0, 0)); - }; - - let mut year = now.year(); - let mut month = now.month(); - let mut start = anchored_month_start(year, month, anchor); - if start > now { - (year, month) = shift_month(year, month, -1); - start = anchored_month_start(year, month, anchor); - } - let (next_year, next_month) = shift_month(year, month, 1); - (start, anchored_month_start(next_year, next_month, anchor)) -} - -fn anchored_month_start(year: i32, month: u32, anchor: DateTime) -> DateTime { - let day = anchor.day().min(days_in_month(year, month)); - utc_date( - year, - month, - day, - anchor.hour(), - anchor.minute(), - anchor.second(), - anchor.nanosecond(), - ) -} - -fn shift_month(year: i32, month: u32, delta: i32) -> (i32, u32) { - let index = year * 12 + month as i32 - 1 + delta; - (index.div_euclid(12), (index.rem_euclid(12) + 1) as u32) -} - -fn days_in_month(year: i32, month: u32) -> u32 { - let (next_year, next_month) = shift_month(year, month, 1); - NaiveDate::from_ymd_opt(next_year, next_month, 1) - .and_then(|next| next.pred_opt()) - .map(|last| last.day()) - .unwrap_or(28) -} - -#[allow(clippy::too_many_arguments)] -fn utc_date( - year: i32, - month: u32, - day: u32, - hour: u32, - minute: u32, - second: u32, - nanosecond: u32, -) -> DateTime { - Utc.with_ymd_and_hms(year, month, day, hour, minute, second) - .single() - .expect("validated UTC date") - .with_nanosecond(nanosecond) - .expect("source nanoseconds are valid") -} - -#[cfg(test)] -mod tests { - use chrono::{TimeZone, Utc}; - - use super::{ - OpenCodeWindows, MONTHLY_CAP_USD, QUOTA_SOURCE_NOTE, SESSION_CAP_USD, SESSION_SECONDS, - WEEKLY_CAP_USD, WEEK_SECONDS, - }; - use crate::models::QuotaFormat; - - fn time(year: i32, month: u32, day: u32, hour: u32, minute: u32) -> chrono::DateTime { - Utc.with_ymd_and_hms(year, month, day, hour, minute, 0) - .unwrap() - } - - #[test] - fn rolling_session_uses_the_oldest_in_window_row_for_reset() { - let now = time(2026, 7, 12, 12, 0); - let windows = OpenCodeWindows::compute( - &[ - (time(2026, 7, 12, 11, 0), 2.0), - (time(2026, 7, 12, 8, 30), 1.5), - (time(2026, 7, 12, 6, 0), 9.0), - ], - None, - now, - ); - assert_eq!(windows.session_spend, 3.5); - assert_eq!(windows.session_resets_at, time(2026, 7, 12, 13, 30)); - } - - #[test] - fn week_is_monday_utc_and_boundaries_are_half_open() { - let now = time(2026, 7, 12, 12, 0); - let windows = OpenCodeWindows::compute( - &[ - (time(2026, 7, 6, 0, 0), 4.0), - (time(2026, 7, 5, 23, 59), 9.0), - (time(2026, 7, 12, 11, 0), 1.0), - ], - None, - now, - ); - assert_eq!(windows.weekly_spend, 5.0); - assert_eq!(windows.weekly_resets_at, time(2026, 7, 13, 0, 0)); - } - - #[test] - fn anchored_month_clamps_short_months_and_uses_the_live_cycle() { - let now = time(2026, 6, 15, 12, 0); - let anchor = time(2026, 1, 31, 9, 30); - let windows = OpenCodeWindows::compute(&[], Some(anchor), now); - assert_eq!(windows.monthly_resets_at, time(2026, 6, 30, 9, 30)); - - let later = OpenCodeWindows::compute(&[], Some(anchor), time(2026, 7, 12, 12, 0)); - assert_eq!(later.monthly_resets_at, time(2026, 7, 31, 9, 30)); - } - - #[test] - fn idle_windows_and_calendar_month_fallback_are_stable() { - let now = time(2026, 7, 12, 12, 0); - let windows = OpenCodeWindows::compute(&[], None, now); - assert_eq!(windows.session_spend, 0.0); - assert_eq!(windows.session_resets_at, time(2026, 7, 12, 17, 0)); - assert_eq!(windows.monthly_resets_at, time(2026, 8, 1, 0, 0)); - } - - #[test] - fn quota_contract_marks_machine_local_caps_as_estimates() { - let windows = OpenCodeWindows::compute(&[], None, time(2026, 7, 12, 12, 0)); - let quotas = windows.quotas(); - assert_eq!( - quotas - .iter() - .map(|quota| quota.id.as_str()) - .collect::>(), - ["session", "weekly", "monthly"] - ); - assert_eq!( - quotas - .iter() - .map(|quota| quota.limit_value) - .collect::>(), - [ - Some(SESSION_CAP_USD), - Some(WEEKLY_CAP_USD), - Some(MONTHLY_CAP_USD) - ] - ); - assert_eq!(quotas[0].period_seconds, SESSION_SECONDS); - assert_eq!(quotas[1].period_seconds, WEEK_SECONDS); - assert!(quotas.iter().all(|quota| { - quota.format == QuotaFormat::Dollars - && quota.unit.as_deref() == Some("usd") - && quota.estimated - && quota.source_note.as_deref() == Some(QUOTA_SOURCE_NOTE) - })); - } -}