Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

## [Windows] Unreleased

- OpenCode Go: reuse unchanged local history scans (keyed by `opencode.db` and its `-wal` sidecar size and modification time, at most one 4 MiB payload kept), and in Auto overlay Console quota from a stored or automatically discovered browser session before falling back to the local estimate; cookie source Off never reads a browser (upstream #4410, #4445).
- Add **Parallel** prepaid balance: reads the account balance and any pending debit from the platform.parallel.ai console using your browser session or a pasted Cookie header. A signed-out console moves on to the next browser session and reports an expired session when none remain (upstream #4479, #4508).
- Add **fal** account credit balance: reads the credit balance and account name from fal's billing API with your API key (`FAL_KEY` or Settings) and explains when the key lacks the billing read permission (upstream #4438).
- Add **FLORA** workspace balance: reads the workspace's available balance, your member limit, and this month's spend with your API key and workspace ID (`FLORA_API_KEY` / `FLORA_WORKSPACE_ID` or Settings), and explains when spend is unavailable or the key lacks billing permission (upstream #4439).
Expand Down
15 changes: 11 additions & 4 deletions apps/desktop-tauri/src-tauri/src/commands/providers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -311,10 +311,17 @@ pub(crate) fn build_fetch_context(
"off" if provider_uses_oauth_without_cookies(id, usage_source) => {
(SourceMode::OAuth, None, false)
}
"off"
if (has_kimi_code_api_key || has_opencodego_api_key)
&& usage_source == SourceMode::Auto =>
{
// OpenCode Go discovers browser sessions inside the provider; the
// missing-cookie flag tells it that Off forbids that discovery.
"off" if id == ProviderId::OpenCodeGo => {
let source_mode = if has_opencodego_api_key && usage_source == SourceMode::Auto {
SourceMode::Auto
} else {
SourceMode::Cli
};
(source_mode, None, true)
}
"off" if has_kimi_code_api_key && usage_source == SourceMode::Auto => {
(SourceMode::Auto, None, false)
}
// Droid/Factory: cookie-off must never scrape browser cookies. Map to
Expand Down
45 changes: 45 additions & 0 deletions apps/desktop-tauri/src-tauri/src/commands/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -682,6 +682,51 @@ fn opencodego_selected_api_account_overrides_global_key_without_changing_explici
}
}

#[test]
fn opencodego_cookie_source_off_forbids_provider_browser_discovery() {
let mut settings = Settings::default();
settings.set_cookie_source(ProviderId::OpenCodeGo, "off");
settings.set_usage_source(ProviderId::OpenCodeGo, "auto");
let no_accounts = HashMap::new();

let without_key = super::build_fetch_context(
ProviderId::OpenCodeGo,
&settings,
&ManualCookies::default(),
&ApiKeys::default(),
&no_accounts,
);
assert_eq!(without_key.source_mode, SourceMode::Cli);
assert!(without_key.manual_cookie_header.is_none());
assert!(without_key.manual_cookie_missing);

let mut keys = ApiKeys::default();
keys.set("opencodego", "global-key", None);
let with_key = super::build_fetch_context(
ProviderId::OpenCodeGo,
&settings,
&ManualCookies::default(),
&keys,
&no_accounts,
);
assert_eq!(with_key.source_mode, SourceMode::Auto);
assert!(with_key.manual_cookie_missing);

// Automatic leaves discovery to the provider, which reads a browser only
// when the local estimate needs a session.
settings.set_cookie_source(ProviderId::OpenCodeGo, "auto");
let auto = super::build_fetch_context(
ProviderId::OpenCodeGo,
&settings,
&ManualCookies::default(),
&keys,
&no_accounts,
);
assert_eq!(auto.source_mode, SourceMode::Auto);
assert!(auto.manual_cookie_header.is_none());
assert!(!auto.manual_cookie_missing);
}

#[test]
fn opencodego_selected_cookie_account_uses_web_route() {
let settings = Settings::default();
Expand Down
60 changes: 56 additions & 4 deletions rust/src/providers/opencodego/local.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@
use chrono::{DateTime, Datelike, Duration, Local, NaiveDate, TimeZone, Timelike, Utc};
use rusqlite::Connection;
use std::path::{Path, PathBuf};
use std::sync::Arc;

use super::tokens::{RowTokens, TokenSums};
use crate::core::{ProviderError, ProviderFetchResult, RateWindow, UsageSnapshot};
use scan_cache::ScanCache;

const FIVE_HOURS_MS: i64 = 5 * 60 * 60 * 1000;
const WEEK_MS: i64 = 7 * 24 * 60 * 60 * 1000;
Expand Down Expand Up @@ -159,7 +161,7 @@ pub fn candidate_paths() -> Vec<(PathBuf, PathBuf)> {
pub fn fetch_local_usage(now: DateTime<Utc>) -> Result<LocalUsageSnapshot, ProviderError> {
let mut last_err: Option<ProviderError> = None;
for (auth, db) in candidate_paths() {
match fetch_from_paths(&auth, &db, now) {
match fetch_from_paths_with_cache(&auth, &db, now, ScanCache::shared()) {
Ok(snap) => return Ok(snap),
Err(e) => last_err = Some(e),
}
Expand All @@ -171,10 +173,21 @@ pub fn fetch_local_usage(now: DateTime<Utc>) -> Result<LocalUsageSnapshot, Provi
}))
}

/// Explicit-path reads (tests, diagnostics) get a private cache so they never
/// observe or populate the process-wide scan cache.
pub fn fetch_from_paths(
auth_path: &Path,
db_path: &Path,
now: DateTime<Utc>,
) -> Result<LocalUsageSnapshot, ProviderError> {
fetch_from_paths_with_cache(auth_path, db_path, now, &ScanCache::new())
}

fn fetch_from_paths_with_cache(
auth_path: &Path,
db_path: &Path,
now: DateTime<Utc>,
cache: &ScanCache,
) -> Result<LocalUsageSnapshot, ProviderError> {
let has_auth = has_auth_key(auth_path);
if !db_path.exists() {
Expand All @@ -189,7 +202,7 @@ pub fn fetch_from_paths(
});
}

let rows = read_rows(db_path)?;
let rows = cache.read(db_path, || read_rows(db_path))?;
if !has_auth && rows.is_empty() {
return Err(ProviderError::NotInstalled(
"OpenCode Go not detected. Log in with OpenCode Go or use it locally first.".into(),
Expand Down Expand Up @@ -532,12 +545,12 @@ pub fn model_cost_summary_scan(now: DateTime<Utc>, days: u32) -> Option<ModelCos
/// Read usage rows from the first candidate install that yields any. Returns
/// `None` when no install is reachable (auth+db both absent) rather than
/// propagating `NotInstalled`, since the cost surfaces treat "no data" as empty.
fn read_available_rows() -> Option<Vec<UsageRow>> {
fn read_available_rows() -> Option<Arc<Vec<UsageRow>>> {
for (auth, db) in candidate_paths() {
if !db.exists() {
continue;
}
match read_rows(&db) {
match ScanCache::shared().read(&db, || read_rows(&db)) {
Ok(rows) if !rows.is_empty() || has_auth_key(&auth) => return Some(rows),
Ok(_) => continue,
Err(_) => continue,
Expand Down Expand Up @@ -632,6 +645,8 @@ fn anchored_month(year: i32, month: u32, anchor: &DateTime<Utc>) -> DateTime<Utc
Utc.from_utc_datetime(&ndt)
}

mod scan_cache;

#[cfg(test)]
mod tokens_tests;

Expand Down Expand Up @@ -954,6 +969,43 @@ mod tests {
);
}

#[test]
fn cached_scan_tracks_live_wal_commits() {
let db = temp_db_path("wal-cache");
let auth = db.with_extension("auth.json");
std::fs::write(&auth, r#"{"opencode-go":{"key":"k"}}"#).unwrap();
let now = Utc.with_ymd_and_hms(2026, 3, 18, 12, 0, 0).unwrap();
let created = now.timestamp_millis() - 60_000;

// The writer stays open so commits land in the -wal sidecar, not the main file.
let writer = Connection::open(&db).unwrap();
writer.execute_batch("PRAGMA journal_mode=WAL;").unwrap();
writer
.execute_batch(
"CREATE TABLE message (id TEXT PRIMARY KEY, data TEXT, time_created INTEGER);",
)
.unwrap();
insert_message(&writer, "m1", created, Some(3.0), None);

let cache = ScanCache::new();
let first = fetch_from_paths_with_cache(&auth, &db, now, &cache).unwrap();
assert!(
(first.rolling_usage_percent - 25.0).abs() < 0.05,
"{first:?}"
);
let cached = cache.read(&db, || -> Result<Vec<UsageRow>, ProviderError> {
panic!("unchanged database must not be rescanned")
});
assert_eq!(cached.unwrap().len(), 1);

insert_message(&writer, "m2", created, Some(3.0), None);
let second = fetch_from_paths_with_cache(&auth, &db, now, &cache).unwrap();
assert!(
(second.rolling_usage_percent - 50.0).abs() < 0.05,
"a WAL commit must invalidate the cached scan: {second:?}"
);
}

// ---- A14: per-model daily cost breakdown (upstream #2649) -------------

fn a14_now() -> DateTime<Utc> {
Expand Down
Loading