From 0288e47ebe95f06b49ecee310101a5b3fe06a622 Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Thu, 15 Oct 2026 22:14:56 +0700 Subject: [PATCH 1/3] Cache unchanged OpenCode Go local scans --- rust/src/providers/opencodego/local.rs | 60 +++- .../providers/opencodego/local/scan_cache.rs | 293 ++++++++++++++++++ 2 files changed, 349 insertions(+), 4 deletions(-) create mode 100644 rust/src/providers/opencodego/local/scan_cache.rs diff --git a/rust/src/providers/opencodego/local.rs b/rust/src/providers/opencodego/local.rs index abb36ff9559..96fc0513b0b 100644 --- a/rust/src/providers/opencodego/local.rs +++ b/rust/src/providers/opencodego/local.rs @@ -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; @@ -159,7 +161,7 @@ pub fn candidate_paths() -> Vec<(PathBuf, PathBuf)> { pub fn fetch_local_usage(now: DateTime) -> Result { let mut last_err: Option = 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), } @@ -171,10 +173,21 @@ pub fn fetch_local_usage(now: DateTime) -> Result, +) -> Result { + 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, + cache: &ScanCache, ) -> Result { let has_auth = has_auth_key(auth_path); if !db_path.exists() { @@ -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(), @@ -532,12 +545,12 @@ pub fn model_cost_summary_scan(now: DateTime, days: u32) -> Option Option> { +fn read_available_rows() -> Option>> { 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, @@ -632,6 +645,8 @@ fn anchored_month(year: i32, month: u32, anchor: &DateTime) -> DateTime Result, 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 { diff --git a/rust/src/providers/opencodego/local/scan_cache.rs b/rust/src/providers/opencodego/local/scan_cache.rs new file mode 100644 index 00000000000..f9f908eea02 --- /dev/null +++ b/rust/src/providers/opencodego/local/scan_cache.rs @@ -0,0 +1,293 @@ +//! Reuse of unchanged OpenCode Go SQLite scans (upstream #4410 / #4445). +//! +//! A scan is keyed by the database file and its SQLite `-wal` sidecar (size +//! and modification time), so WAL commits, checkpoints, and sidecar removal +//! all invalidate it. Only one parsed-row payload of at most +//! [`MAX_CACHED_PAYLOAD_BYTES`] stays resident; larger histories are still +//! read, just not retained between scans. + +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, PoisonError}; +use std::time::SystemTime; + +use super::UsageRow; + +/// Row storage plus model text, excluding allocator overhead. +const MAX_CACHED_PAYLOAD_BYTES: usize = 4 * 1024 * 1024; + +#[derive(Debug, Clone, PartialEq, Eq)] +struct FileStamp { + size: u64, + modified: Option, +} + +impl FileStamp { + fn of(path: &Path) -> Option { + let metadata = std::fs::metadata(path).ok()?; + if !metadata.is_file() { + return None; + } + Some(Self { + size: metadata.len(), + modified: metadata.modified().ok(), + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct DatabaseFingerprint { + path: PathBuf, + database: FileStamp, + wal: Option, +} + +impl DatabaseFingerprint { + fn current(db_path: &Path) -> Option { + let path = std::fs::canonicalize(db_path).unwrap_or_else(|_| db_path.to_path_buf()); + let database = FileStamp::of(&path)?; + let wal = FileStamp::of(&wal_path(&path)); + Some(Self { + path, + database, + wal, + }) + } +} + +fn wal_path(db_path: &Path) -> PathBuf { + crate::core::sqlite_sidecar_path(db_path, "-wal") +} + +fn fits_cache_budget(rows: &[UsageRow]) -> bool { + let row_size = std::mem::size_of::(); + if rows.len() > MAX_CACHED_PAYLOAD_BYTES / row_size { + return false; + } + let payload = rows + .iter() + .fold(0usize, |total, row| total + row_size + row.model.len()); + payload <= MAX_CACHED_PAYLOAD_BYTES +} + +type CacheEntry = (DatabaseFingerprint, Arc>); + +pub(in crate::providers::opencodego) struct ScanCache { + entry: Mutex>, +} + +impl ScanCache { + pub(in crate::providers::opencodego) const fn new() -> Self { + Self { + entry: Mutex::new(None), + } + } + + /// Process-wide cache shared by the provider fetch and cost-history scans. + pub(in crate::providers::opencodego) fn shared() -> &'static Self { + static SHARED: ScanCache = ScanCache::new(); + &SHARED + } + + /// Return cached rows when the database fingerprint is unchanged, + /// otherwise run `scan`. The lock is held across the scan so concurrent + /// refreshes share one read instead of scanning the same file twice. + pub(in crate::providers::opencodego) fn read( + &self, + db_path: &Path, + scan: impl FnOnce() -> Result, E>, + ) -> Result>, E> { + let mut entry = self.entry.lock().unwrap_or_else(PoisonError::into_inner); + let fingerprint = DatabaseFingerprint::current(db_path); + if let (Some(fingerprint), Some((cached, rows))) = (&fingerprint, entry.as_ref()) + && fingerprint == cached + { + return Ok(Arc::clone(rows)); + } + *entry = None; + let rows = Arc::new(scan()?); + // A commit landing mid-scan changes the fingerprint; caching those rows + // under the pre-scan key would serve stale data until the next write. + if let Some(fingerprint) = fingerprint + && DatabaseFingerprint::current(db_path).as_ref() == Some(&fingerprint) + && fits_cache_budget(&rows) + { + *entry = Some((fingerprint, Arc::clone(&rows))); + } + Ok(rows) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::Cell; + use std::io::Write; + + fn temp_dir(label: &str) -> PathBuf { + let nanos = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or_default(); + let dir = std::env::temp_dir().join(format!( + "codexbar-opencodego-scan-cache-{label}-{}-{nanos}", + std::process::id() + )); + std::fs::create_dir_all(&dir).expect("create temp dir"); + dir + } + + fn row(created_ms: i64, model: &str) -> UsageRow { + UsageRow { + created_ms, + cost: 1.0, + request_count: 1, + model: model.to_string(), + tokens: None, + } + } + + fn append(path: &Path, bytes: &[u8]) { + let mut file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + .expect("open fixture"); + file.write_all(bytes).expect("write fixture"); + } + + fn counting_scan<'a>( + scans: &'a Cell, + rows: Vec, + ) -> impl FnOnce() -> Result, ()> + 'a { + move || { + scans.set(scans.get() + 1); + Ok(rows) + } + } + + #[test] + fn unchanged_fingerprint_reuses_the_previous_scan() { + let dir = temp_dir("hit"); + let db = dir.join("opencode.db"); + append(&db, b"db"); + append(&wal_path(&db), b"wal"); + let cache = ScanCache::new(); + let scans = Cell::new(0); + + let first = cache + .read(&db, counting_scan(&scans, vec![row(1, "a")])) + .unwrap(); + let second = cache + .read(&db, counting_scan(&scans, vec![row(2, "b")])) + .unwrap(); + + assert_eq!(scans.get(), 1); + assert!(Arc::ptr_eq(&first, &second)); + assert_eq!(second[0].created_ms, 1); + } + + #[test] + fn wal_commit_appearance_and_removal_invalidate_the_scan() { + let dir = temp_dir("wal"); + let db = dir.join("opencode.db"); + let wal = wal_path(&db); + append(&db, b"db"); + let cache = ScanCache::new(); + let scans = Cell::new(0); + + cache.read(&db, counting_scan(&scans, vec![])).unwrap(); + append(&wal, b"frame-1"); + cache.read(&db, counting_scan(&scans, vec![])).unwrap(); + assert_eq!(scans.get(), 2, "a new WAL sidecar must rescan"); + + append(&wal, b"frame-2"); + let rows = cache + .read(&db, counting_scan(&scans, vec![row(9, "after-commit")])) + .unwrap(); + assert_eq!(scans.get(), 3, "a WAL commit must rescan"); + assert_eq!(rows[0].model, "after-commit"); + + std::fs::rename(&wal, dir.join("checkpointed-wal")).expect("move fixture wal"); + cache.read(&db, counting_scan(&scans, vec![])).unwrap(); + assert_eq!(scans.get(), 4, "a checkpointed (removed) WAL must rescan"); + + cache.read(&db, counting_scan(&scans, vec![])).unwrap(); + assert_eq!(scans.get(), 4); + } + + #[test] + fn database_change_and_other_paths_invalidate_the_scan() { + let dir = temp_dir("db"); + let db = dir.join("opencode.db"); + let other = dir.join("other.db"); + append(&db, b"db"); + append(&other, b"db"); + let cache = ScanCache::new(); + let scans = Cell::new(0); + + cache.read(&db, counting_scan(&scans, vec![])).unwrap(); + append(&db, b"grown"); + cache.read(&db, counting_scan(&scans, vec![])).unwrap(); + cache.read(&other, counting_scan(&scans, vec![])).unwrap(); + assert_eq!(scans.get(), 3); + } + + #[test] + fn change_during_scan_is_not_cached() { + let dir = temp_dir("mid-scan"); + let db = dir.join("opencode.db"); + append(&db, b"db"); + let cache = ScanCache::new(); + let scans = Cell::new(0); + + cache + .read(&db, || { + scans.set(scans.get() + 1); + append(&wal_path(&db), b"concurrent-commit"); + Ok::<_, ()>(vec![row(1, "stale")]) + }) + .unwrap(); + cache.read(&db, counting_scan(&scans, vec![])).unwrap(); + assert_eq!(scans.get(), 2); + } + + #[test] + fn scan_errors_and_missing_databases_are_not_cached() { + let dir = temp_dir("errors"); + let db = dir.join("opencode.db"); + let cache = ScanCache::new(); + let scans = Cell::new(0); + + cache.read(&db, counting_scan(&scans, vec![])).unwrap(); + cache.read(&db, counting_scan(&scans, vec![])).unwrap(); + assert_eq!(scans.get(), 2, "a missing database has no fingerprint"); + + append(&db, b"db"); + let error = cache.read(&db, || Err::, _>("locked")); + assert_eq!(error.unwrap_err(), "locked"); + cache.read(&db, counting_scan(&scans, vec![])).unwrap(); + assert_eq!(scans.get(), 3); + } + + #[test] + fn payloads_over_the_memory_budget_are_returned_but_not_retained() { + let dir = temp_dir("budget"); + let db = dir.join("opencode.db"); + append(&db, b"db"); + let cache = ScanCache::new(); + let scans = Cell::new(0); + let oversized_model = "m".repeat(MAX_CACHED_PAYLOAD_BYTES); + + let rows = cache + .read(&db, counting_scan(&scans, vec![row(1, &oversized_model)])) + .unwrap(); + assert_eq!(rows.len(), 1); + cache.read(&db, counting_scan(&scans, vec![])).unwrap(); + assert_eq!(scans.get(), 2); + + let row_size = std::mem::size_of::(); + let too_many_rows = MAX_CACHED_PAYLOAD_BYTES / row_size + 1; + assert!(!fits_cache_budget(&vec![row(1, ""); too_many_rows])); + assert!(fits_cache_budget(&vec![row(1, ""); too_many_rows - 1])); + } +} From 9bf1c1940873930e4d04412aec6e1d6197f72841 Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Thu, 15 Oct 2026 22:41:12 +0700 Subject: [PATCH 2/3] Overlay OpenCode Go browser session onto local estimate --- .../src-tauri/src/commands/providers.rs | 15 +- .../src-tauri/src/commands/tests.rs | 45 ++ rust/src/providers/opencodego/mod.rs | 92 +++-- .../providers/opencodego/session_overlay.rs | 383 ++++++++++++++++++ 4 files changed, 491 insertions(+), 44 deletions(-) create mode 100644 rust/src/providers/opencodego/session_overlay.rs diff --git a/apps/desktop-tauri/src-tauri/src/commands/providers.rs b/apps/desktop-tauri/src-tauri/src/commands/providers.rs index 6e1f812d070..9b733c9cff0 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/providers.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/providers.rs @@ -296,10 +296,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 diff --git a/apps/desktop-tauri/src-tauri/src/commands/tests.rs b/apps/desktop-tauri/src-tauri/src/commands/tests.rs index 101828efb60..b7c529d16f7 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/tests.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/tests.rs @@ -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(); diff --git a/rust/src/providers/opencodego/mod.rs b/rust/src/providers/opencodego/mod.rs index 2fe2ef060f5..c638e705d97 100644 --- a/rust/src/providers/opencodego/mod.rs +++ b/rust/src/providers/opencodego/mod.rs @@ -2,12 +2,14 @@ //! //! Separate workspace surface that shares the `opencode.ai` cookie domain with //! the OpenCode provider. Auto prefers local SQLite usage (upstream #2316) -//! unless a workspace override scopes the fetch to web first; Web is cookie -//! scrape only; Cli is local-only. +//! unless a workspace override scopes the fetch to web first, and overlays +//! API or browser-session quota onto the local estimate (upstream #4445); +//! Web is cookie scrape only; Cli is local-only. mod console; mod legacy; pub(crate) mod local; +mod session_overlay; mod tokens; mod transport; mod usage_api; @@ -503,10 +505,11 @@ impl Provider for OpenCodeGoProvider { } Err(e) => return Err(e), } - return self.fetch_local_with_balance(ctx).await; + // The scoped session just failed; do not retry it as an overlay. + return self.fetch_local_with_balance(ctx, false).await; } - match self.fetch_local_with_balance(ctx).await { + match self.fetch_local_with_balance(ctx, true).await { Ok(result) => return Ok(result), Err(e) => { tracing::debug!("OpenCode Go local failed in Auto; trying API/web: {e}"); @@ -523,7 +526,7 @@ impl Provider for OpenCodeGoProvider { self.fetch_web(ctx).await } SourceMode::Web => self.fetch_web(ctx).await, - SourceMode::Cli => self.fetch_local_with_balance(ctx).await, + SourceMode::Cli => self.fetch_local_with_balance(ctx, false).await, SourceMode::OAuth => { let api_key = if ctx.token_account_isolated { usage_api::selected_account_api_key(ctx).ok_or(ProviderError::AuthRequired)? @@ -551,6 +554,12 @@ impl Provider for OpenCodeGoProvider { fn supports_cli(&self) -> bool { true } + + /// An API result needs a browser session only for the Zen balance, so the + /// provider imports one when it will use it instead of on every refresh. + fn owns_browser_cookie_resolution(&self) -> bool { + true + } } /// Keep a selected API-key account on its own identity. Explicit web and local @@ -607,60 +616,63 @@ impl OpenCodeGoProvider { ) } - /// Local SQLite snapshot plus the optional bounded Zen balance enrichment - /// (upstream #2583 waits for the balance in usage-snapshot reads too, not - /// just web reads; cookie absence or a slow/broken billing lookup degrades - /// to no balance, never to an error). + /// Local SQLite snapshot plus optional enrichment: API meters first, then + /// (when `overlay_session` is set) Console quota from a provided or + /// discovered browser session (upstream #4410 / #4445), and the bounded + /// Zen balance (upstream #2583). Enrichment failures degrade to the local + /// estimate, never to an error. async fn fetch_local_with_balance( &self, ctx: &FetchContext, + overlay_session: bool, ) -> Result { let snap = local::fetch_local_usage(Utc::now())?; - let mut result = snap.to_fetch_result(); + let local = snap.to_fetch_result(); + let transport = Arc::new(HttpWebTransport::new(self.client.clone())); + let session = session_overlay::SessionCookie::for_context(ctx); + if let Some(api_key) = usage_api::resolve_api_key(ctx) && let Ok(api_result) = usage_api::fetch(&self.client, ctx, &api_key, "local+api").await { - result = api_result; + return Ok( + Self::with_optional_session_balance(ctx, api_result, session, transport).await, + ); + } + if overlay_session { + let cookie_header = session.resolve(session_overlay::discover_browser_session); + return Ok( + Self::overlay_local_with_session(ctx, local, cookie_header, transport).await, + ); } + Ok(Self::with_optional_session_balance(ctx, local, session, transport).await) + } + + async fn with_optional_session_balance( + ctx: &FetchContext, + result: ProviderFetchResult, + session: session_overlay::SessionCookie, + transport: Arc, + ) -> ProviderFetchResult { if !ctx.include_credits { - return Ok(result); + return result; } - let cookie_header = match ctx.manual_cookie_header.clone() { - Some(header) => Some(header), - None => crate::providers::browser_cookie_header(&["opencode.ai"]).ok(), - }; - let Some(cookie_header) = cookie_header else { - return Ok(result); - }; - let cookies = WebCookieSession::new(&cookie_header); - let transport = Arc::new(HttpWebTransport::new(self.client.clone())); - let (task, started) = Self::spawn_zen_balance_task( - Arc::clone(&transport), - &cookies, - ctx.workspace_id.as_deref(), - ctx.web_timeout, - ); - if let Some(balance) = Self::finish_zen_balance( - transport, - &cookies, - task, - started, - ctx.requires_optional_usage_completeness, - ) - .await - { - result = - Self::with_zen_balance(result.usage, &result.source_label.clone(), Some(balance)); + match session.resolve(session_overlay::discover_browser_session) { + Some(cookie_header) => { + Self::attach_zen_balance(ctx, result, &cookie_header, transport).await + } + None => result, } - Ok(result) } async fn fetch_web(&self, ctx: &FetchContext) -> Result { if let Some(cookie_header) = &ctx.manual_cookie_header { return self.fetch_with_cookies(ctx, cookie_header).await; } + if ctx.manual_cookie_missing { + return Err(ProviderError::AuthRequired); + } - match crate::providers::browser_cookie_header(&["opencode.ai"]) { + match crate::providers::browser_cookie_header(session_overlay::COOKIE_DOMAINS) { Ok(cookie_header) => self.fetch_with_cookies(ctx, &cookie_header).await, Err(ProviderError::NoCookies) => Err(ProviderError::AuthRequired), Err(e) => Err(e), diff --git a/rust/src/providers/opencodego/session_overlay.rs b/rust/src/providers/opencodego/session_overlay.rs new file mode 100644 index 00000000000..dd9cefda0aa --- /dev/null +++ b/rust/src/providers/opencodego/session_overlay.rs @@ -0,0 +1,383 @@ +//! Session enrichment for the local estimate (upstream #4410 / #4445). +//! +//! Without an authoritative API result, Auto overlays Console quota from a +//! provided session or, when none is provided, from a browser session found +//! with the shared browser order. A missing, blocked, or rejected discovered +//! session keeps the local estimate. Cookie source Off (signalled by the shell +//! as `manual_cookie_missing`) never reads a browser. + +use std::sync::Arc; + +use super::transport::WebTransport; +use super::{OpenCodeGoProvider, WebCookieSession}; +use crate::core::{FetchContext, ProviderError, ProviderFetchResult}; + +pub(super) const LOCAL_WEB_SOURCE_LABEL: &str = "local+web"; +pub(super) const COOKIE_DOMAINS: &[&str] = &["opencode.ai"]; + +/// Where the web session for an overlay or Zen balance comes from. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(super) enum SessionCookie { + /// A stored manual cookie or a selected token account's cookie. + Provided(String), + /// No session was provided; the browsers may be read. + Discover, + /// The user turned cookies off; no browser may be read. + Disabled, +} + +impl SessionCookie { + pub(super) fn for_context(ctx: &FetchContext) -> Self { + match ctx + .manual_cookie_header + .as_deref() + .map(str::trim) + .filter(|header| !header.is_empty()) + { + Some(header) => Self::Provided(header.to_string()), + None if ctx.manual_cookie_missing => Self::Disabled, + None => Self::Discover, + } + } + + /// Resolve to a header. `discover` runs only for [`Self::Discover`]. + pub(super) fn resolve( + self, + discover: impl FnOnce() -> Result, + ) -> Option { + match self { + Self::Provided(header) => Some(header), + Self::Disabled => None, + Self::Discover => match discover() { + Ok(header) if !header.trim().is_empty() => Some(header), + Ok(_) => None, + Err(error) => { + tracing::debug!("OpenCode Go browser session discovery found none: {error}"); + None + } + }, + } + } +} + +pub(super) fn discover_browser_session() -> Result { + crate::providers::browser_cookie_header(COOKIE_DOMAINS) +} + +impl OpenCodeGoProvider { + /// Overlay authoritative Console quota onto the local estimate. Errors never + /// escape: a rejected session keeps the estimate, and any other failure + /// still tries the optional Zen balance with the same session. + pub(super) async fn overlay_local_with_session( + ctx: &FetchContext, + local: ProviderFetchResult, + cookie_header: Option, + transport: Arc, + ) -> ProviderFetchResult { + let Some(cookie_header) = cookie_header else { + return local; + }; + match Self::fetch_with_transport(ctx, &cookie_header, Arc::clone(&transport)).await { + Ok(mut web) => { + web.source_label = LOCAL_WEB_SOURCE_LABEL.to_string(); + web + } + Err(ProviderError::AuthRequired | ProviderError::NoCookies) => local, + Err(error) => { + tracing::debug!( + "OpenCode Go session overlay unavailable; keeping estimate: {error}" + ); + if ctx.include_credits { + Self::attach_zen_balance(ctx, local, &cookie_header, transport).await + } else { + local + } + } + } + } + + /// Attach the optional Zen balance without changing the result's source or + /// pace authority. + pub(super) async fn attach_zen_balance( + ctx: &FetchContext, + result: ProviderFetchResult, + cookie_header: &str, + transport: Arc, + ) -> ProviderFetchResult { + let cookies = WebCookieSession::new(cookie_header); + let (task, started) = Self::spawn_zen_balance_task( + Arc::clone(&transport), + &cookies, + ctx.workspace_id.as_deref(), + ctx.web_timeout, + ); + let Some(balance) = Self::finish_zen_balance( + transport, + &cookies, + task, + started, + ctx.requires_optional_usage_completeness, + ) + .await + else { + return result; + }; + let pace_authoritative = result.pace_authoritative; + let mut enriched = + Self::with_zen_balance(result.usage, &result.source_label, Some(balance)); + enriched.pace_authoritative = pace_authoritative; + enriched + } +} + +#[cfg(test)] +mod tests { + use super::super::{console, legacy}; + use super::*; + use crate::core::{RateWindow, UsageSnapshot}; + use async_trait::async_trait; + use std::cell::Cell; + use std::sync::Mutex; + use std::time::Duration; + + const CONSOLE_COOKIE: &str = "__Host-console_session=console"; + + enum UsageOutcome { + Quota(f64), + Rejected, + Unavailable, + } + + struct FakeConsole { + usage: UsageOutcome, + balance: Option, + usage_cookies: Mutex>, + } + + impl FakeConsole { + fn new(usage: UsageOutcome, balance: Option) -> Arc { + Arc::new(Self { + usage, + balance, + usage_cookies: Mutex::new(Vec::new()), + }) + } + } + + #[async_trait] + impl WebTransport for FakeConsole { + type LegacySession = (); + + async fn fetch_workspace_id( + &self, + _cookie_header: &str, + _timeout: Duration, + ) -> Result { + Ok("wrk_discovered".to_string()) + } + + async fn fetch_console_usage( + &self, + _workspace_id: &str, + cookie_header: &str, + _timeout: Duration, + ) -> Result { + self.usage_cookies + .lock() + .unwrap() + .push(cookie_header.to_string()); + match self.usage { + UsageOutcome::Quota(percent) => Ok(console::ConsoleUsage::Snapshot(Box::new( + UsageSnapshot::new(RateWindow::with_details(percent, Some(300), None, None)), + ))), + UsageOutcome::Rejected => Err(ProviderError::AuthRequired), + UsageOutcome::Unavailable => { + Err(ProviderError::Other("console unavailable".to_string())) + } + } + } + + async fn fetch_console_balance( + &self, + _workspace_id: &str, + _cookie_header: &str, + _timeout: Duration, + ) -> Result, ProviderError> { + Ok(self.balance) + } + + async fn resolve_legacy_session( + &self, + _cookies: &WebCookieSession, + ) -> Result { + panic!("console-only sessions never resolve a legacy session") + } + + async fn fetch_legacy_usage( + &self, + _session: &Self::LegacySession, + ) -> Result { + panic!("console-only sessions never fetch legacy usage") + } + + async fn fetch_legacy_balance( + &self, + _session: &Self::LegacySession, + _timeout: Duration, + ) -> Result, ProviderError> { + panic!("console-only sessions never fetch a legacy balance") + } + } + + fn local_estimate() -> ProviderFetchResult { + ProviderFetchResult::new( + UsageSnapshot::new(RateWindow::with_details(10.0, Some(300), None, None)), + super::super::LOCAL_ESTIMATE_SOURCE_LABEL, + ) + .with_non_authoritative_pace() + } + + fn completeness_ctx() -> FetchContext { + FetchContext { + web_timeout: 1, + requires_optional_usage_completeness: true, + ..FetchContext::default() + } + } + + #[test] + fn provided_session_wins_over_discovery_and_off_never_discovers() { + let discovered = Cell::new(0); + let discover = || { + discovered.set(discovered.get() + 1); + Ok("from=browser".to_string()) + }; + + let provided = FetchContext { + manual_cookie_header: Some(" from=manual ".to_string()), + manual_cookie_missing: true, + ..FetchContext::default() + }; + assert_eq!( + SessionCookie::for_context(&provided), + SessionCookie::Provided("from=manual".to_string()) + ); + assert_eq!( + SessionCookie::for_context(&provided).resolve(discover), + Some("from=manual".to_string()) + ); + + let off = FetchContext { + manual_cookie_header: Some(" ".to_string()), + manual_cookie_missing: true, + ..FetchContext::default() + }; + assert_eq!(SessionCookie::for_context(&off), SessionCookie::Disabled); + assert_eq!(SessionCookie::for_context(&off).resolve(discover), None); + assert_eq!(discovered.get(), 0); + + let auto = FetchContext::default(); + assert_eq!(SessionCookie::for_context(&auto), SessionCookie::Discover); + assert_eq!( + SessionCookie::for_context(&auto).resolve(discover), + Some("from=browser".to_string()) + ); + assert_eq!(discovered.get(), 1); + } + + #[test] + fn missing_or_blocked_browser_sessions_resolve_to_none() { + assert_eq!( + SessionCookie::Discover.resolve(|| Err(ProviderError::NoCookies)), + None + ); + assert_eq!( + SessionCookie::Discover + .resolve(|| Err(ProviderError::Other("App-Bound Encryption".to_string()))), + None + ); + assert_eq!( + SessionCookie::Discover.resolve(|| Ok(" ".to_string())), + None + ); + } + + #[tokio::test] + async fn discovered_session_overlays_authoritative_console_quota() { + let transport = FakeConsole::new(UsageOutcome::Quota(42.0), Some(7.5)); + let result = OpenCodeGoProvider::overlay_local_with_session( + &completeness_ctx(), + local_estimate(), + Some(CONSOLE_COOKIE.to_string()), + Arc::clone(&transport), + ) + .await; + + assert_eq!(result.source_label, LOCAL_WEB_SOURCE_LABEL); + assert!(result.pace_authoritative); + assert_eq!(result.usage.primary.used_percent, 42.0); + assert_eq!(result.cost.unwrap().used, 7.5); + assert_eq!( + transport.usage_cookies.lock().unwrap().as_slice(), + [CONSOLE_COOKIE] + ); + } + + #[tokio::test] + async fn rejected_session_keeps_the_local_estimate() { + let transport = FakeConsole::new(UsageOutcome::Rejected, Some(7.5)); + let result = OpenCodeGoProvider::overlay_local_with_session( + &completeness_ctx(), + local_estimate(), + Some(CONSOLE_COOKIE.to_string()), + transport, + ) + .await; + + assert_eq!( + result.source_label, + super::super::LOCAL_ESTIMATE_SOURCE_LABEL + ); + assert!(!result.pace_authoritative); + assert_eq!(result.usage.primary.used_percent, 10.0); + assert!(result.cost.is_none()); + } + + #[tokio::test] + async fn unavailable_console_keeps_the_estimate_with_zen_balance() { + let transport = FakeConsole::new(UsageOutcome::Unavailable, Some(7.5)); + let result = OpenCodeGoProvider::overlay_local_with_session( + &completeness_ctx(), + local_estimate(), + Some(CONSOLE_COOKIE.to_string()), + transport, + ) + .await; + + assert_eq!( + result.source_label, + super::super::LOCAL_ESTIMATE_SOURCE_LABEL + ); + assert!(!result.pace_authoritative, "estimate must stay estimated"); + assert_eq!(result.usage.primary.used_percent, 10.0); + assert_eq!(result.cost.unwrap().used, 7.5); + } + + #[tokio::test] + async fn no_session_keeps_the_estimate_without_web_requests() { + let transport = FakeConsole::new(UsageOutcome::Quota(42.0), Some(7.5)); + let result = OpenCodeGoProvider::overlay_local_with_session( + &completeness_ctx(), + local_estimate(), + None, + Arc::clone(&transport), + ) + .await; + + assert_eq!( + result.source_label, + super::super::LOCAL_ESTIMATE_SOURCE_LABEL + ); + assert!(transport.usage_cookies.lock().unwrap().is_empty()); + } +} From 5d4329aa3c7eb02fec485c63291895e49f96460d Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Thu, 15 Oct 2026 22:41:13 +0700 Subject: [PATCH 3/3] Note OpenCode Go scan cache and session discovery --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c2cbfb2ff8..11b5f803629 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). - JetBrains AI: read current quota from newer `JbaiQuota` and Junie `quota_update` records in `idea.log`, keeping same-IDE freshness and the XML fallback (upstream #4457). - Raycast: automatic cookie import walks every supported browser in the shared order and uses the first accepted session, instead of reading Chrome alone (upstream #4443, 0aef66c73). - Price Codex **codex-auto-review** requests made from a ChatGPT account at $0 from 2026-10-06 07:00 UTC onward: tokens stay in totals, the model is no longer listed as unpriced, and an explicit custom price for the model still applies (upstream #4452, #4453).