Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 6 additions & 7 deletions docs/providers/opencode.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
64 changes: 64 additions & 0 deletions src-tauri/src/providers/opencode/client.rs
Original file line number Diff line number Diff line change
@@ -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, OpenCodeError> {
Self::with_endpoint(GO_USAGE_URL, Duration::from_secs(15))
}

fn with_endpoint(usage_url: &str, timeout: Duration) -> Result<Self, OpenCodeError> {
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<UsageResponse, OpenCodeError> {
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 })
}
}
65 changes: 2 additions & 63 deletions src-tauri/src/providers/opencode/database.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(\
Expand All @@ -25,7 +23,6 @@ const PROVIDER_JSON: &str = "COALESCE(\
#[derive(Debug, Default)]
pub(crate) struct DatabaseUsage {
pub(crate) records: Vec<UsageRecord>,
pub(crate) go_anchor: Option<DateTime<Utc>>,
}

pub(crate) enum DatabaseRead {
Expand All @@ -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(..) {
Expand All @@ -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<bool, ()> {
Expand Down Expand Up @@ -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<Option<DateTime<Utc>>, ()> {
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<DateTime<Utc>> = 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<DateTime<Utc>> = 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::<Value>(&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<String> {
match row.get_ref(index).ok()? {
ValueRef::Text(value) | ValueRef::Blob(value) => {
Expand Down
81 changes: 81 additions & 0 deletions src-tauri/src/providers/opencode/mapper.rs
Original file line number Diff line number Diff line change
@@ -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<Vec<QuotaWindow>, 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<QuotaWindow, OpenCodeError> {
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));
}
}
Loading