diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index d55efe6ac0..c26f7ece10 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -1037,7 +1037,7 @@ OpenCode reads usage from `opencode.ai` cookies. Which API it uses depends on th - OpenCode reuses the OpenCode Go Console client (`providers/opencodego/console.rs`). The monthly Go meter appears only on OpenCode Go. - For OpenCode Go, a manual cookie keeps the configured usage source (Auto, Web or Local) instead of forcing Web (upstream #4616). The cookie scopes only the web session. The source label shows the source that actually supplied the result, such as `web` or `local estimate`. -OpenCode Go's local SQLite reader reads only its own `opencode-go` assistant records. OpenAI API-platform usage is a separate provider. +OpenCode Go's local SQLite reader reads only records attributed to `opencode-go`: legacy assistant messages and v2 `session_message` assistant and compaction requests. A v2 request is skipped only when a legacy copy with the same id has a usable timestamp and a non-negative finite cost, so migrated requests count once and an invalid legacy copy never hides a valid v2 request. V2-only databases (no `message` table, no `auth.json`) still read. OpenAI API-platform usage is a separate provider. For `codexbar cost --provider opencodego` (upstream #4629), token counts follow the same rule per row. A row with missing, malformed, negative or overflowing tokens is unknown, not zero, and any window or model containing one has no exact token total. A row that records a total but omits input or output keeps that exact total, while its input and output are unknown. The text report says `unknown` for the affected totals while still reporting cost. The JSON payload uses `null` for them and adds `unknownTokenRowCount` and `byModelTokens`. Per-model token counts sum only what rows recorded. The `spendContract` per-model rows still carry those usable partial counts; the desktop Usage & Spend view is unchanged. diff --git a/rust/src/providers/opencodego/local.rs b/rust/src/providers/opencodego/local.rs index d5989feedc..cda8c714a7 100644 --- a/rust/src/providers/opencodego/local.rs +++ b/rust/src/providers/opencodego/local.rs @@ -21,6 +21,8 @@ const WEEKLY_LIMIT_USD: f64 = 30.0; const MONTHLY_LIMIT_USD: f64 = 60.0; const PROVIDER_ID: &str = "opencode-go"; +/// Legacy `message` rows. Every legacy arm yields `(createdMs, cost, requestCount, modelID, tokens, +/// messageID)` so the v2 arm can anti-join against them by message id. const MESSAGE_USAGE_SQL: &str = r#" SELECT CAST(COALESCE(json_extract(data, '$.time.created'), time_created) AS INTEGER) AS createdMs, @@ -28,7 +30,8 @@ SELECT 1 AS requestCount, COALESCE(json_extract(data, '$.modelID'), '') AS modelID, CASE WHEN json_type(data, '$.tokens') = 'object' - THEN json_extract(data, '$.tokens') END AS tokens + THEN json_extract(data, '$.tokens') END AS tokens, + id AS messageID FROM message WHERE json_valid(data) AND json_extract(data, '$.providerID') = 'opencode-go' @@ -36,6 +39,13 @@ WHERE json_valid(data) AND json_type(data, '$.cost') IN ('integer', 'real') "#; +/// Legacy placeholder for databases that have no `message` table (v2-only installs). +const NO_LEGACY_USAGE_SQL: &str = r#" +SELECT NULL AS createdMs, NULL AS cost, NULL AS requestCount, NULL AS modelID, NULL AS tokens, + NULL AS messageID +WHERE 0 +"#; + const MESSAGE_AND_PART_USAGE_SQL: &str = r#" WITH provider_messages AS ( SELECT @@ -50,51 +60,55 @@ WITH provider_messages AS ( WHERE json_valid(data) AND json_extract(data, '$.providerID') = 'opencode-go' AND json_extract(data, '$.role') = 'assistant' +), provider_parts AS NOT MATERIALIZED ( + SELECT + CAST(COALESCE(json_extract(p.data, '$.time.created'), p.time_created, m.createdMs) AS INTEGER) + AS createdMs, + CAST(json_extract(p.data, '$.cost') AS REAL) AS cost, + 1 AS requestCount, + m.modelID AS modelID, + CASE WHEN json_type(p.data, '$.tokens') = 'object' + THEN json_extract(p.data, '$.tokens') END AS tokens, + m.messageID AS messageID + FROM part p + JOIN provider_messages m ON m.messageID = p.message_id + WHERE json_valid(p.data) + AND json_extract(p.data, '$.type') = 'step-finish' + AND json_type(p.data, '$.cost') IN ('integer', 'real') ) -SELECT - CAST(COALESCE(json_extract(p.data, '$.time.created'), p.time_created, m.createdMs) AS INTEGER) - AS createdMs, - CAST(json_extract(p.data, '$.cost') AS REAL) AS cost, - 1 AS requestCount, - m.modelID AS modelID, - CASE WHEN json_type(p.data, '$.tokens') = 'object' - THEN json_extract(p.data, '$.tokens') END AS tokens -FROM part p -JOIN provider_messages m ON m.messageID = p.message_id -WHERE json_valid(p.data) - AND json_extract(p.data, '$.type') = 'step-finish' - AND json_type(p.data, '$.cost') IN ('integer', 'real') +SELECT createdMs, cost, requestCount, modelID, tokens, messageID FROM provider_parts UNION ALL -SELECT createdMs, cost, 1 AS requestCount, modelID, tokens +SELECT createdMs, cost, 1 AS requestCount, modelID, tokens, messageID FROM provider_messages m WHERE hasCost - AND NOT EXISTS ( - SELECT 1 - FROM part p - WHERE p.message_id = m.messageID - AND json_valid(p.data) - AND json_extract(p.data, '$.type') = 'step-finish' - AND json_type(p.data, '$.cost') IN ('integer', 'real') - ) + AND NOT EXISTS (SELECT 1 FROM provider_parts p WHERE p.messageID = m.messageID) "#; -/// OpenCode v2 writes new assistant turns to `session_message` and copies older turns there under the -/// same ids. Only turns the legacy `message` table does not already hold are added, so migrated turns -/// are counted once. +/// OpenCode v2 writes new turns to `session_message` and copies older turns there under the same ids. +/// A v2 row is skipped only when a usable legacy copy exists (positive timestamp, finite non-negative +/// cost), so a malformed legacy copy cannot hide a valid v2 request. Compaction summaries carry the +/// request cost too, so they count as well. const SESSION_MESSAGE_USAGE_SQL: &str = r#" +UNION ALL SELECT CAST(COALESCE(json_extract(s.data, '$.time.created'), s.time_created) AS INTEGER) AS createdMs, CAST(json_extract(s.data, '$.cost') AS REAL) AS cost, 1 AS requestCount, COALESCE(json_extract(s.data, '$.model.id'), '') AS modelID, CASE WHEN json_type(s.data, '$.tokens') = 'object' - THEN json_extract(s.data, '$.tokens') END AS tokens + THEN json_extract(s.data, '$.tokens') END AS tokens, + s.id AS messageID FROM session_message s -WHERE s.type = 'assistant' +WHERE s.type IN ('assistant', 'compaction') AND json_valid(s.data) AND json_extract(s.data, '$.model.providerID') = 'opencode-go' AND json_type(s.data, '$.cost') IN ('integer', 'real') - AND NOT EXISTS (SELECT 1 FROM message m WHERE m.id = s.id) + AND NOT EXISTS ( + SELECT 1 FROM legacy_usage m + WHERE m.messageID = s.id + AND m.createdMs > 0 + AND m.cost BETWEEN 0 AND 1.7976931348623157e308 + ) "#; #[derive(Debug, Clone)] @@ -245,13 +259,26 @@ fn read_rows(db_path: &Path) -> Result, ProviderError> { conn.busy_timeout(std::time::Duration::from_millis(250)) .map_err(sqlite_err)?; - let legacy_sql = if has_table(&conn, "part") { + let has_messages = has_table(&conn, "message"); + let has_v2 = has_table(&conn, "session_message"); + if !has_messages && !has_v2 { + return Err(ProviderError::Other( + "SQLite error reading OpenCode Go usage: no message or session_message table".into(), + )); + } + let legacy_sql = if !has_messages { + NO_LEGACY_USAGE_SQL + } else if has_table(&conn, "part") { MESSAGE_AND_PART_USAGE_SQL } else { MESSAGE_USAGE_SQL }; - let sql = if has_table(&conn, "session_message") { - format!("{legacy_sql}\nUNION ALL\n{SESSION_MESSAGE_USAGE_SQL}") + // The legacy arm is a CTE shared by the v2 anti-join. NOT MATERIALIZED lets SQLite inline it + // rather than keep a second copy of the legacy history. + let sql = if has_v2 { + format!( + "WITH legacy_usage AS NOT MATERIALIZED ({legacy_sql})\nSELECT * FROM legacy_usage{SESSION_MESSAGE_USAGE_SQL}" + ) } else { legacy_sql.to_owned() }; diff --git a/rust/src/providers/opencodego/local/test_db.rs b/rust/src/providers/opencodego/local/test_db.rs index 220ec444bb..305d75457e 100644 --- a/rust/src/providers/opencodego/local/test_db.rs +++ b/rust/src/providers/opencodego/local/test_db.rs @@ -85,6 +85,22 @@ pub(super) fn insert_session_message( .unwrap(); } +/// Insert a raw v2 `session_message` row with any `type` and payload, including malformed JSON. +pub(super) fn insert_session_message_raw( + conn: &Connection, + id: &str, + ty: &str, + created_ms: i64, + data: &str, +) { + conn.execute( + "INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data) + VALUES (?1, 'session-1', ?2, 0, ?3, ?3, ?4)", + rusqlite::params![id, ty, created_ms, data], + ) + .unwrap(); +} + /// Insert a step-finish part carrying a cost, attached to `message_id`. pub(super) fn insert_step_finish( conn: &Connection, diff --git a/rust/src/providers/opencodego/local/tests.rs b/rust/src/providers/opencodego/local/tests.rs index c34117107e..822acd1154 100644 --- a/rust/src/providers/opencodego/local/tests.rs +++ b/rust/src/providers/opencodego/local/tests.rs @@ -1,6 +1,6 @@ use super::test_db::{ - create_session_message_table, insert_message, insert_session_message, insert_step_finish, - iso_ms, open_db, write_message_db, + create_session_message_table, insert_message, insert_session_message, + insert_session_message_raw, insert_step_finish, iso_ms, open_db, write_message_db, }; use super::*; use crate::core::RateWindow; @@ -613,6 +613,304 @@ fn malformed_v2_session_messages_are_ignored_without_failing_the_read() { assert!((rows[0].cost - 4.0).abs() < 1e-6); } +/// Noon local time on the day the migrated-copy fixture rows are anchored to (upstream #4587). +fn v2_history_now() -> DateTime { + Local + .with_ymd_and_hms(2026, 3, 6, 12, 0, 0) + .single() + .unwrap() + .with_timezone(&Utc) +} + +#[test] +fn v2_only_database_without_message_table_reads_requests() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("opencode.db"); + let conn = Connection::open(&db).unwrap(); + create_session_message_table(&conn); + insert_session_message_raw( + &conn, + "msg_go", + "assistant", + 1_772_794_800_000, + r#"{"agent":"build","model":{"providerID":"opencode-go","id":"fixture-model"},"content":[],"cost":3,"time":{"created":1772794800000}}"#, + ); + drop(conn); + + // No auth.json and no legacy `message` table: v2 history alone must still produce usage. + let auth = dir.path().join("auth.json"); + let now = a14_now(); + let snap = fetch_from_paths(&auth, &db, now).expect("v2-only history must read"); + assert!((snap.rolling_usage_percent - 25.0).abs() < 0.05, "{snap:?}"); + + let rows = read_rows(&db).unwrap(); + assert_eq!(rows.len(), 1, "{rows:?}"); + let buckets = daily_model_costs(&rows, now, 30); + assert_eq!(buckets.len(), 1, "{buckets:?}"); + assert_eq!(buckets[0].model, "fixture-model"); + assert!((buckets[0].cost - 3.0).abs() < 1e-6); + assert_eq!(buckets[0].request_count, 1); +} + +#[test] +fn legacy_copies_count_once_and_new_v2_turns_are_added() { + for has_parts in [false, true] { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("opencode.db"); + let conn = open_db(&db, has_parts); + create_session_message_table(&conn); + let day_start_ms = Local + .with_ymd_and_hms(2026, 3, 6, 0, 0, 0) + .single() + .unwrap() + .timestamp_millis(); + let before = day_start_ms - 60_000; + let after = day_start_ms + 60_000; + insert_message(&conn, "msg_old", before, Some(4.0), None, None); + insert_message(&conn, "msg_migrated", before, Some(3.0), None, None); + if has_parts { + // Step-finish parts take precedence over the migrated message's own cost. + insert_step_finish(&conn, "prt_1", "msg_migrated", before, 1.0, None); + insert_step_finish(&conn, "prt_2", "msg_migrated", after, 2.0, None); + } + insert_session_message( + &conn, + "msg_migrated", + before, + 3.0, + "fixture-model", + "opencode-go", + None, + ); + insert_session_message( + &conn, + "msg_new", + after, + 5.0, + "fixture-model", + "opencode-go", + None, + ); + drop(conn); + + let rows = read_rows(&db).unwrap(); + let buckets = daily_model_costs(&rows, v2_history_now(), 30); + let mut by_day: std::collections::BTreeMap = + std::collections::BTreeMap::new(); + for bucket in &buckets { + let entry = by_day.entry(bucket.day_key.clone()).or_default(); + entry.0 += bucket.cost; + entry.1 += bucket.request_count; + } + let days: Vec<(f64, u32)> = by_day.values().copied().collect(); + assert_eq!(days.len(), 2, "has_parts={has_parts}: {buckets:?}"); + let (before_cost, after_cost) = if has_parts { (5.0, 7.0) } else { (7.0, 5.0) }; + assert!( + (days[0].0 - before_cost).abs() < 1e-6, + "has_parts={has_parts}: {days:?}" + ); + assert!( + (days[1].0 - after_cost).abs() < 1e-6, + "has_parts={has_parts}: {days:?}" + ); + let requests: u32 = days.iter().map(|day| day.1).sum(); + assert_eq!( + requests, + if has_parts { 4 } else { 3 }, + "has_parts={has_parts}" + ); + } +} + +#[test] +fn invalid_legacy_copies_do_not_hide_valid_v2_requests() { + for has_legacy in [false, true] { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("opencode.db"); + let conn = open_db(&db, false); + create_session_message_table(&conn); + let created = 1_772_794_800_000; + if has_legacy { + // A legacy copy with a negative cost is unusable and must not hide the v2 request. + insert_message(&conn, "msg_go", created, Some(-3.0), None, None); + } + insert_session_message( + &conn, + "msg_go", + created, + 3.0, + "fixture-model", + "opencode-go", + None, + ); + insert_session_message_raw( + &conn, + "msg_zero", + "assistant", + created, + r#"{"model":{"providerID":"opencode-go"},"cost":0}"#, + ); + let invalid_payloads = [ + "{", + r#"{"model":{"providerID":"opencode"},"cost":99}"#, + r#"{"model":{"providerID":"another-provider"},"cost":99}"#, + r#"{"providerID":"opencode-go","role":"assistant","cost":99}"#, + r#"{"model":{"providerID":"opencode-go"},"cost":"99"}"#, + r#"{"model":{"providerID":"opencode-go"},"cost":null}"#, + r#"{"model":{"providerID":"opencode-go"}}"#, + r#"{"model":{"providerID":"opencode-go"},"cost":-1}"#, + r#"{"model":{"providerID":"opencode-go"},"cost":1e999}"#, + ]; + for (index, data) in invalid_payloads.iter().enumerate() { + insert_session_message_raw( + &conn, + &format!("msg_invalid_{index}"), + "assistant", + created, + data, + ); + } + insert_session_message_raw( + &conn, + "msg_user", + "user", + created, + r#"{"model":{"providerID":"opencode-go"},"cost":99}"#, + ); + drop(conn); + + let now = a14_now(); + let rows = read_rows(&db).unwrap(); + assert_eq!(rows.len(), 2, "has_legacy={has_legacy}: {rows:?}"); + let buckets = daily_model_costs(&rows, now, 30); + assert_eq!(buckets.len(), 2, "has_legacy={has_legacy}: {buckets:?}"); + assert_eq!(buckets[0].model, "fixture-model"); + assert!((buckets[0].cost - 3.0).abs() < 1e-6); + assert_eq!(buckets[0].request_count, 1); + assert_eq!(buckets[1].model, UNKNOWN_MODEL_NAME); + assert!(buckets[1].cost.abs() < 1e-6); + assert_eq!(buckets[1].request_count, 1); + } +} + +#[test] +fn legacy_copy_with_unusable_timestamp_does_not_hide_valid_v2_request() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("opencode.db"); + let conn = open_db(&db, false); + create_session_message_table(&conn); + // No `time.created` and `time_created` 0 gives a legacy createdMs of 0, which is unusable. + conn.execute( + "INSERT INTO message (id, data, time_created) VALUES ('msg_clock', ?1, 0)", + rusqlite::params![r#"{"providerID":"opencode-go","role":"assistant","cost":3}"#], + ) + .unwrap(); + insert_session_message( + &conn, + "msg_clock", + 1_772_794_800_000, + 3.0, + "fixture-model", + "opencode-go", + None, + ); + drop(conn); + + let rows = read_rows(&db).unwrap(); + assert_eq!(rows.len(), 1, "{rows:?}"); + assert_eq!(rows[0].model, "fixture-model"); + assert!((rows[0].cost - 3.0).abs() < 1e-6); +} + +#[test] +fn idle_v2_wal_history_reads_without_changing_the_database_or_creating_sidecars() { + for has_legacy in [false, true] { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("opencode.db"); + { + let conn = Connection::open(&db).unwrap(); + conn.execute_batch("PRAGMA journal_mode=WAL;").unwrap(); + create_session_message_table(&conn); + if has_legacy { + conn.execute_batch( + "CREATE TABLE message (id TEXT PRIMARY KEY, data TEXT NOT NULL, time_created INTEGER);", + ) + .unwrap(); + } + insert_session_message( + &conn, + "msg_go", + 1_772_794_800_000, + 3.0, + "fixture-model", + "opencode-go", + None, + ); + conn.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |_| Ok(())) + .unwrap(); + drop(conn); + } + // Idle WAL: the writer's checkpointed sidecars are gone. Park them rather than delete. + for suffix in ["-wal", "-shm"] { + let side = crate::core::sqlite_sidecar_path(&db, suffix); + if side.exists() { + let parked = PathBuf::from(format!("{}.parked", side.display())); + std::fs::rename(&side, parked).unwrap(); + } + } + let wal = crate::core::sqlite_sidecar_path(&db, "-wal"); + let shm = crate::core::sqlite_sidecar_path(&db, "-shm"); + assert!( + !wal.exists() && !shm.exists(), + "precondition: no sidecars (has_legacy={has_legacy})" + ); + let contents = std::fs::read(&db).unwrap(); + + let rows = read_rows(&db).expect("read idle v2 WAL history"); + assert_eq!(rows.len(), 1, "has_legacy={has_legacy}: {rows:?}"); + assert!((rows[0].cost - 3.0).abs() < 1e-6); + + assert_eq!( + std::fs::read(&db).unwrap(), + contents, + "reader must not modify the database" + ); + assert!( + !wal.exists() && !shm.exists(), + "reader must not create -wal/-shm sidecars (has_legacy={has_legacy})" + ); + } +} + +#[test] +fn v2_compaction_requests_count_with_their_cost_and_tokens() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("opencode.db"); + let conn = open_db(&db, false); + create_session_message_table(&conn); + insert_session_message_raw( + &conn, + "msg_compaction", + "compaction", + 1_772_794_800_000, + r#"{"status":"completed","reason":"auto","summary":"Summary","recent":"", + "model":{"providerID":"opencode-go","id":"fixture-model"},"cost":2, + "tokens":{"input":2,"output":1,"reasoning":0,"cache":{"read":0,"write":0}}}"#, + ); + drop(conn); + + let rows = read_rows(&db).unwrap(); + assert_eq!(rows.len(), 1, "{rows:?}"); + assert_eq!(rows[0].model, "fixture-model"); + assert!((rows[0].cost - 2.0).abs() < 1e-6); + assert_eq!(rows[0].tokens.as_ref().map(|t| t.total), Some(3)); + + let buckets = daily_model_costs(&rows, a14_now(), 30); + assert_eq!(buckets.len(), 1, "{buckets:?}"); + assert!((buckets[0].cost - 2.0).abs() < 1e-6); + assert_eq!(buckets[0].request_count, 1); +} + #[test] fn step_finish_parts_inherit_their_model_from_the_parent_message() { let dir = tempfile::tempdir().unwrap();