Skip to content

fix(opencode): support OpenCode 2 session_message schema and credential table - #1243

Closed
arpankanwer wants to merge 7 commits into
robinebers:mainfrom
arpankanwer:fix/opencode2-session-message-compat-v2
Closed

arpankanwer wants to merge 7 commits into
robinebers:mainfrom
arpankanwer:fix/opencode2-session-message-compat-v2

Conversation

@arpankanwer

@arpankanwer arpankanwer commented Sep 10, 2026 •

Copy link
Copy Markdown
Contributor

Approved issue

Fixes #1124

Supersedes #1186 (that PR could not be reopened — GitHub refuses while its head branch has been force-pushed after close, so this branch is a clean replacement, rebased onto current main).

TL;DR

OpenUsage fails on OpenCode 2 — "Couldn't read OpenCode's local database" and no Go meters — because the scanners only query the v1 message table and the auth store only reads auth.json. This adds OpenCode 2 support: both OpenCode scanners union message + session_message with coalesced JSON paths, and credentials fall back from auth.json to the SQLite credential table.

What was happening

OpenCode 2 changed local storage in two places:

  • message (role, providerID, modelID, tokens.total) → session_message (columns type, seq, data; type='assistant' replaces $.role, with $.model.providerID, $.model.id, and tokens.input/output/reasoning/cache.* — no total)
  • ~/.local/share/opencode/auth.json → the SQLite credential table (integration_id='opencode-go', value JSON {"key":"sk-..."})

On a v2 database the scan hits no such table: message (or reads only stale pre-upgrade rows) and goAPIKey() returns nil, so the card reports an unreadable database and drops all Go meters.

Measured on a migrated local ~/.local/share/opencode/opencode.db:

hosted rows
message (v1) 24,893
session_message (v2) 21,823
union 46,716

Both tables persist after migration, so a v1-only read roughly halves the spend tiles and reports 0 for any day after the upgrade.

What this changes

  • OpenCodeUsageScanner — dataSQL/probeSQL union both tables with coalesced JSON paths: COALESCE($.model.providerID,$.providerID), COALESCE($.model.id,$.modelID), and COALESCE($.tokens.total, input+output+reasoning+cache.read+cache.write). message keeps its $.role='assistant' filter; session_message accepts assistant plus completed compactions. 2.0.3 migrates legacy rows under their original IDs, so copies are deduplicated by ID across tables and channel databases instead of counted twice.

  • OpenCodeCodexUsageScanner — same v2 blind spot from feat(codex): attribute OpenCode Codex OAuth usage and share Codex request pricing #1195 (it matched $.providerID = 'openai' on the v1 table only), so Codex OAuth usage recorded after an upgrade counted as nothing. Now unions both tables with the columns projected once outside the union. Tokens fall back to the per-bucket sum because OpenCode 2 dropped $.tokens.total. Completed compactions count with their own status-based condition. v2 rows older than the current OAuth credential are excluded: experimental builds zeroed every cost including paid traffic, so those rows may be paid history rather than subscription usage (v1 rows priced paid traffic correctly and are unaffected).

  • OpenCodeAuthStore — reads the live credential table first (OpenCode 2 imports auth.json without deleting it, so a retained file key goes stale; the file stays the OpenCode 1 fallback), still throwing credentialsUnreadable on a malformed file. Every lookup takes the current row first (active DESC, time_updated DESC, id DESC, admitting NULL-flagged imports) instead of an arbitrary LIMIT 1 row. Go keys stay scoped to opencode-go/opencode.

  • OpenCodePaths — owns the OpenCodeMessageTables set and the sqlite_master probe every scanner runs first.

  • docs/providers/opencode.md, docs/providers/codex.md — document both credential sources, both message tables, and the credential-table location for the Codex OAuth lookup.

  • OpenCodeProvider — a failed Go meters request no longer hides local tiles: it logs and falls through to the scan, surfacing as an error only when there is nothing else to show.

Heads-up

  • No migration, no new dependency, and no change for existing installs: auth.json is still tried first and v1 rows still count.
  • BYO-key scope is unchanged — still only opencode-go/opencode on the OpenCode card, so Total Spend cannot double-count against the OpenRouter or Cursor cards (OpenCode card drops BYO-key provider usage (~26% of local spend) #1088 is a separate, larger question).
  • Each scanner probes sqlite_master once per database and builds its query from the tables that are actually present (v1 only, v2 only, or the union), so a database holding only one of them is read rather than reported unreadable. A file with neither table is skipped. This costs one extra lightweight query per database per refresh.
  • I could not exercise the OpenCode 2 path in CI — there is no v2 fixture in the suite. Verification below is a real migrated database, plus the stub SQLite.

Tests

swift build                   → Build complete
swift test                    → 1356 tests, 3 skipped, 0 failures
swift test --filter OpenCode  → 83 passed

New coverage: migrated-copy dedupe (same ID twice and across channel DBs counts once), completed-compaction counting on both scanners, current-row credential ordering, DB-beats-stale-file in both directions, v2 OAuth-age bound (pre-credential excluded, post-credential and v1 included), Go-failure-keeps-tiles at provider level, schema-less exclusion from failure counting on both scanners (failed usable plus skipped sibling still throws/returns nil; all-skipped stays an empty result), the credential-table fallback for goAPIKey() and hasCodexOAuth() (including auth.json winning when both exist, and an API-key credential not counting as OAuth), both fallback queries naming their integration (no unscoped LIKE 'sk-%'), the best-effort nil contract on credential DB failures, a v2 assertion on the Codex scanner SQL, and hermetic databasePaths: { [] } on the auth-store tests so they can no longer read this machine's real databases. The existing testQuerySelectsOnlyCompletedOpenAIRows asserted the substring providerID') = 'openai', which only the v1 form could satisfy; it now asserts the coalesced predicate actually executed.

Manual checks against a real OpenCode 2 database, running the scanners' own SQL:

OpenCode card, 30 days, union over both tables → 18,098 rows · 1,188,825,621 tokens · $13.62
  message only (v1)                            →  9,941 rows ·   646,561,838 tokens ·  $7.36
  session_message only (v2)                    →  8,157 rows ·   542,263,783 tokens

Codex attribution, same predicate shape       → 384 in message, 213 in session_message, 597 unioned
credential lookup                             → returns the opencode-go sk- key

Before/after through the local HTTP API on the same machine:

before   Today  $0    ·  0 tokens      Last 30 Days   $7.36 · 646.6M
after    Today  $1.14 ·  97M tokens    Last 30 Days  $13.62 ·   1.2B

Screenshots

Not applicable — no visual change.

…al table

OpenCode 2 (1.18.x/beta) moved local storage:
- message table (role/providerID/modelID/tokens.total) -> session_message
  table (type=assistant, model.providerID, model.id,
  tokens.input/output/reasoning/cache.* without total)
- auth.json {"opencode-go":{"key":"sk-..."}} -> SQLite credential
  table (integration_id='opencode-go', value JSON {"key":"sk-..."})

Previously OpenUsage queried only 'message' with old JSON paths and
only read auth.json, so on OpenCode 2 it showed
"Couldn't read OpenCode's local database" and no Go meters.

Fix:
- Scanner: UNION ALL message + session_message with coalesced JSON
  paths (providerID, modelID, tokens) so either schema or both
  during migration works. probe and data queries updated.
- AuthStore: try auth.json first, then fallback to credential table
  across all opencode*.db files (opencode-go then generic sk-%),
  injected via SQLiteAccessing for testability, backwards compatible.

Resolves robinebers#1124

Test: swift build, swift test --filter OpenCode (40 passed),
swift test full (1225 passed), manual sqlite verification on
~/.local/share/opencode/opencode.db (46716 union vs 24893 v1-only)
…ibution

The Codex attribution scan (robinebers#1195) still queried only the v1 'message' table
with v1 JSON paths, so on OpenCode 2 it matched nothing: the provider moved
from $.providerID to $.model.providerID, assistant rows moved from
$.role to the type column, and $.modelID became $.model.id.

Union both tables the same way the OpenCode card's own scanner does, then
project once outside the union so the ten columns parseRows expects are
written a single time. Tokens fall back to the sum of the per-bucket counts
because OpenCode 2 dropped $.tokens.total.

Also falls back to the credential table when looking for the openai OAuth
entry, since OpenCode 2 no longer writes auth.json.

No behaviour change for OpenCode 1: the v1 branch keeps its existing
predicates and the file still wins over the credential table.

The old test asserted the SQL contained "providerID') = 'openai'", a
substring that only the v1 form could satisfy; it now asserts the coalesced
predicate that is actually run.

Local differential on a migrated database (provider=opencode-go, cost=0,
completed): 384 rows in message, 213 in session_message, 597 unioned.
@github-actions github-actions Bot added the tests label Sep 10, 2026
@arpankanwer
arpankanwer marked this pull request as ready for review September 10, 2026 16:02
Copilot AI lite review requested due to automatic review settings September 10, 2026 16:02

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The scanners’ SQL unconditionally references both message and session_message, which will fail outright if either table is absent (e.g., OpenCode 2 DBs without the legacy message table), recreating the “database unreadable” failure mode.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR updates the OpenCode provider integration to support OpenCode 2’s local storage changes (new session_message table schema and credentials stored in SQLite), restoring OpenCode spend tiles/trend scanning and Go/Codex credential detection.

Changes:

  • Update OpenCode local-usage scanners to read OpenCode 2’s session_message rows in addition to legacy message rows, with coalesced JSON paths and token-total fallback logic.
  • Update OpenCodeAuthStore to read credentials from auth.json first, then fall back to the SQLite credential table for OpenCode 2.
  • Expand tests and provider docs to cover the new schema and credential sources.
File summaries
File Description
Tests/OpenUsageTests/OpenCodeUsageScannerTests.swift Extends the SQLite stub to support credential-table lookups for auth-store fallback tests.
Tests/OpenUsageTests/OpenCodeCodexUsageScannerTests.swift Updates SQL assertions to match coalesced provider/model paths and adds a v2 coverage assertion.
Tests/OpenUsageTests/OpenCodeAuthStoreTests.swift Adds hermetic databasePaths injection and new tests for credential-table fallback behavior.
Sources/OpenUsage/Providers/OpenCode/OpenCodeUsageScanner.swift Unions message + session_message with coalesced JSON extraction for OpenCode 2 schema.
Sources/OpenUsage/Providers/OpenCode/OpenCodeCodexUsageScanner.swift Unions message + session_message for Codex attribution and updates token-total fallback logic.
Sources/OpenUsage/Providers/OpenCode/OpenCodeAuthStore.swift Adds SQLite credential-table fallback for Go key and Codex OAuth detection.
docs/providers/opencode.md Documents OpenCode 1 vs 2 credential/log locations and the unioned-table behavior.
docs/providers/codex.md Documents that OpenCode OAuth credentials may live in auth.json or the credential table (OpenCode 2).
Review details
  • Files reviewed: 8/8 changed files
  • Comments generated: 2
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +202 to +206
AND (json_type(data,'$.time.completed') IN ('integer','real')
OR json_type(data,'$.finish') = 'text')
UNION ALL
SELECT time_created, id, data FROM session_message
WHERE time_created >= \(creationCutoffMs)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 08cf190 — the Codex attribution scanner got the same treatment: it probes sqlite_master per database and queries only the tables present, skipping a database that has neither instead of dropping every other database's rows. See the reply on the sibling comment for what I could and couldn't confirm about the v2-only scenario.

Comment on lines +202 to +206
AND COALESCE(json_extract(data,'$.model.providerID'), json_extract(data,'$.providerID')) IN \(providerFilter)
AND json_type(data,'$.cost') IN ('integer','real')
UNION ALL
SELECT 1 FROM session_message
WHERE type = 'assistant'

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 08cf190.

Both scanners now probe sqlite_master once per database and build the query from the tables that are actually present — v1 only, v2 only, or the union — and a database with neither is skipped rather than counted as a failure. OpenCodeMessageTables in OpenCodePaths.swift holds the set and the probe; the projection is written once per scanner and wrapped around whichever bodies are selected, so the three variants can't drift.

Worth recording what I could and couldn't confirm about the trigger, because it affects how likely this is to bite:

  • A fresh database does not come up v2-only. packages/core/src/database/migration.ts builds an empty database with schema.up(tx) from schema.gen.ts, which creates both message (line 128) and session_message (line 170).
  • An upgraded database keeps both: message is created by the earliest migration and no migration drops it (the only DROP TABLE in the v2 migration is session_entry).
  • So on current OpenCode every database has both tables, and I could not reproduce the failure against a real install.

The scenario is still worth defending against, since #1124's own repro reports a database with no message table on 1.18.18, and the cost is one extra lightweight query per database per refresh. Verified against real and synthetic databases: probe 1|1 on a current install, 0|1 on a session_message-only file, and the correct variant runs in each case.

The previous union named both 'message' and 'session_message' unconditionally.
SQLite fails statement preparation when a named table is absent, so a database
that holds only one of them was reported as unreadable - the same breakage this
change set exists to fix. Caught in review.

Probe sqlite_master once per database, then build the query for the tables that
are actually there: v1 only, v2 only, or the union. A database with neither is
skipped rather than failing, since a file matching opencode*.db that has no
message tables has no usage to read.

Every install today has both tables - OpenCode creates them from its current
schema when it builds a fresh database and no migration drops the old one - so
this is hardening, not a live regression. It costs one extra lightweight query
per database per refresh and makes the schema assumption explicit instead of
implicit.

The projection is now written once per scanner and wrapped around whichever
table bodies are selected, so the variants cannot drift apart. A test asserts
the single-table SQL never names the other table, and another covers the
skip-instead-of-fail path.
@validatedev

Copy link
Copy Markdown
Collaborator

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 08cf190fa5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +24 to +25
private static let credentialSQLAnyKey =
"SELECT json_extract(value,'$.key') FROM credential WHERE json_extract(value,'$.key') LIKE 'sk-%' LIMIT 1;"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Restrict the fallback to OpenCode credentials

When the credential table has no opencode-go row but contains another provider's API key beginning with sk-—for example, an OpenAI BYO key—this unscoped query returns that unrelated secret. goAPIKey() then treats it as Go authentication and sends it as a Bearer token to the OpenCode usage endpoint, both auto-enabling the wrong provider and disclosing a third-party credential to opencode.ai; restrict the fallback to the intended OpenCode integration IDs.

AGENTS.md reference: AGENTS.md:L33-L35

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f71527d — good catch, that was a real disclosure bug.

The fallback was opencode-go, then an unscoped LIKE 'sk-%', so a database with no Go row but a BYO key (e.g. OpenAI) would return that key and goAPIKey() would send it as a Bearer token to the usage endpoint. It now tries opencode-go, then opencode, and nothing else. Added testCredentialFallbackQueriesAreScopedToOpenCodeIntegrations asserting both queries name their integration.

Comment on lines +141 to +142
} catch {
continue

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Propagate credential database read failures

When an OpenCode 2 user has no auth.json and this SQLite query fails because the database is locked or unreadable, the catch converts the failure into a missing credential. For Codex attribution, hasCodexOAuth() consequently returns false and OpenCodeCodexUsageScanner exits at its authentication guard before its database failure reporter runs, so OpenCode-originated Codex usage disappears without any warning; ignore only expected absent rows/tables and propagate or log genuine access errors.

AGENTS.md reference: AGENTS.md:L71-L73

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f71527d.

credentialValue now distinguishes the two failure kinds: a missing credential table (pre-OpenCode-2 database) still skips silently, while anything else — locked, I/O, permissions, unreadable data directory — is logged via AppLog.warn. It still returns nil rather than throwing, so the Go path keeps reporting the scanner's own databaseUnreadable instead of a misleading credential error; the Codex path now at least leaves a log trail instead of vanishing silently.

One deliberate trade-off: this logs per refresh while a failure persists, where the scanners edge-trigger. A locked OpenCode database is rare enough that loudness wins over thrift here, per the fail-loud rule. Added testCredentialDatabaseFailuresReturnNilInsteadOfThrowing pinning the best-effort nil contract.

arpankanwer and others added 2 commits September 11, 2026 14:19
Two review findings on the OpenCode 2 credential fallback.

P1: the second-chance lookup was an unscoped LIKE 'sk-%', so a database with
no opencode-go row but a BYO key (e.g. OpenAI) would return that key, and
goAPIKey() would send it as a Bearer token to the usage endpoint - disclosing
a third-party secret and enabling the wrong provider. The fallback now tries
opencode-go, then opencode, and nothing else.

P2: every credential-table failure was swallowed, so a locked or unreadable
database read as 'no credential'. On the Codex path the scanner exits at its
auth guard before its own failure reporter runs, and the usage disappeared
with no signal. Missing tables (pre-OpenCode-2 databases) still skip
silently; anything else is logged. Still returns nil rather than throwing, so
the Go path keeps reporting the scanner's databaseUnreadable.
@validatedev

Copy link
Copy Markdown
Collaborator

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 18a90deff0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

if let json = try sqlite.queryValue(path: path, sql: Self.dataSQL(cutoffMs: cutoffMs)) {
// A database with no message tables has no usage to read; skip it instead of letting
// the query fail and paint the whole provider as unreadable.
guard let tables = try messageTables(in: path) else { continue }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Exclude schema-less databases from the success count

When one discovered database has no message tables but every database that does have them fails its probe or query, this continue leaves the schema-less path in checked; consequently failures.count != checked.count, and the scanner returns a successful empty scan instead of databaseUnreadable. In a multi-channel installation this can replace real spend history with “No data” merely because an unrelated opencode*.db was readable, so track the contributing/message-table paths separately when deciding whether every usable database failed.

AGENTS.md reference: AGENTS.md:L71-L73

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 2335ce7 — you're right, and the arithmetic is exactly as you describe.

Skipped paths stayed in checked, so with one schema-less file plus one failed usable database, failures.count (1) != checked.count (2) and the scanner returned an empty scan instead of databaseUnreadable. Both scanners now count only databases that actually have message tables: skipped files vote on nothing, a probe throw still counts as a failure, and all-skipped stays an empty result rather than an error.

For the record on whose lines these are, since the review header names the merge commit: the loop scaffolding (checked/failures and the throw) is upstream's, but the skip that broke its counting is mine (08cf190) — so this one is on the PR, not on main.

Verified: new tests cover failed-usable-plus-skipped-sibling (throws/nil) and all-skipped (empty result) on both scanners. Full suite: 1344 passed, 3 skipped, 0 failures.

The table probe.skip sits inside a loop that decides failure by comparing
failures against checked. A skipped path stayed in checked, so one readable
but table-less database made failures.count != checked.count even when every
usable database failed - returning an empty scan instead of databaseUnreadable
on the OpenCode card, or an empty supplement instead of none on the Codex side.

Only databases with message tables are counted now; all-skipped stays an
empty result, not an error. Caught in review.

@robinebers robinebers left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I’m requesting changes because this can show incorrect spend totals and keep using an old account’s credentials. Migrated requests can be counted twice, while the model requests used to summarize long conversations are left out. Older OpenCode 2 history can also put paid OpenAI API-key requests into Codex subscription totals. Zen-only users can lose their local spend tiles when the Go service is unavailable.

The credential lookup needs to follow the account OpenCode currently uses: it can select both an inactive database credential and a stale key left in auth.json. Following the app’s advice to log in again will not reliably fix either case.

I found six actionable issues, detailed inline. Verification: the build succeeded; the original focused suite had 179 tests pass, one skipped, and no failures. Temporary checks against real SQLite credentials and message fixtures reproduced all six issues. Those checks were removed after the review; no source changes were made.

Reviewed commit: 2335ce79566a1aa0e8125c3a8c76b0fa134dc766.

— Rob’s AI Reviewer

tables.contains(.v1) ? v1 : nil,
tables.contains(.v2) ? v2 : nil,
].compactMap { $0 }
return "(\n" + bodies.map(indented).joined(separator: "\n UNION ALL\n") + "\n )"

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Avoid Counting Migrated Messages Twice

OpenCode 2.0.3 copies legacy messages into session_message, preserving their IDs, timestamps, costs and tokens while retaining the original message rows. This UNION ALL counts both copies, and the projection leaves out the IDs needed to deduplicate them. A real SQLite check returned $4 and 1,000 tokens for one migrated $2, 500-token request.

Carry message IDs through and deduplicate across both tables and channel databases, as the Codex scanner already does.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f3079cc — and the local database says this was the biggest correctness issue in the PR: 20,607 hosted-filtered IDs exist in both tables, so the union was overcounting by roughly 40%.

Message IDs now ride through the projection as a sixth column and copies are deduplicated across tables and channel databases before accumulating, preferring the later timestamp then the larger token count (same rule the Codex scanner already used). Verified with same-ID-twice fixtures plus a same-row-on-two-databases case. Full suite: 1356 passed, 3 skipped, 0 failures.

Comment on lines +21 to +26
static let credentialSQLGoKey =
"SELECT json_extract(value,'$.key') FROM credential WHERE integration_id = 'opencode-go' AND json_extract(value,'$.key') LIKE 'sk-%' LIMIT 1;"
static let credentialSQLOpencodeKey =
"SELECT json_extract(value,'$.key') FROM credential WHERE integration_id = 'opencode' AND json_extract(value,'$.key') LIKE 'sk-%' LIMIT 1;"
static let credentialSQLCodexOAuth =
"SELECT value FROM credential WHERE integration_id = 'openai' AND json_extract(value,'$.type') = 'oauth' LIMIT 1;"

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Select the Current Database Credential Before Checking Its Type

OpenCode 2 keeps previous credentials as inactive when connecting a new account. These unordered queries returned an old Go key despite a newer active key, and an inactive OAuth entry despite the current OpenAI credential being an API key. This can show the old account’s quotas, cause persistent authentication failures, or incorrectly pass the Codex OAuth guard.

Select the current credential using OpenCode’s active/time/ID ordering before checking its key or auth type. Filtering for OAuth before choosing the current row still resurrects an inactive account; requiring active=1 alone would miss imported credentials whose active value is NULL.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f3079cc. All four credential queries now use your ordering verbatim — ORDER BY active DESC, time_updated DESC, id DESC — with (active IS NULL OR active = 1) so imported NULL-flagged rows stay eligible without an unordered inactive row winning. The Codex check takes the current row first and only then tests type = oauth, so a live API key is no longer masked by a stale OAuth row.

Verified against a synthetic table (active = 0/1/NULL mix): the active row wins, NULL imports remain selectable, and the current-API-key-over-stale-OAuth case returns the key. A SQL-shape test pins the clause on all four queries.

Comment on lines +67 to +71
if let object = try authObject(),
let entry = object["opencode-go"] as? [String: Any],
let key = entry["key"] as? String,
let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty {
return trimmed

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Do Not Let the Retained Legacy File Hide Updated Credentials

OpenCode 2 imports auth.json without deleting or updating it. Subsequent login changes SQLite, but this early return keeps choosing the old file key. After rotating a key or switching accounts, users can receive persistent 401s or the old account’s quotas; logging in again only changes the database that this branch ignores. The real SQLite/file check returned sk-old despite the current database key being sk-new.

Resolve the applicable credential source, or try the current database credential after rejection of the retained legacy key.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f3079cc by resolving the source: the live database now wins, and auth.json is the fallback for OpenCode 1 (no credential table reads as absent, so v1 behavior is unchanged). Covered both directions in tests — stale file key plus live DB key returns the DB key, and a stale file OAuth claim no longer masks a live API-key row.

One residual edge worth naming: a v2 logout that removes the DB row while a stale file key remains will still surface the file key. Nothing local distinguishes that from a v1 install, so it keeps today's behavior rather than guessing.

Comment on lines +236 to +242
AND type = 'assistant'
AND json_valid(data)
AND COALESCE(json_extract(data,'$.model.providerID'), json_extract(data,'$.providerID')) = 'openai'
AND json_type(data,'$.cost') IN ('integer','real')
AND json_extract(data,'$.cost') = 0
AND (json_type(data,'$.time.completed') IN ('integer','real')
OR json_type(data,'$.finish') = 'text');
OR json_type(data,'$.finish') = 'text')

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Include the Model Requests Used for Compaction

OpenCode 2 records automatic and manual conversation-summary requests as type='compaction', status='completed', with their own model, cost and tokens. Both scanners’ assistant-only filters leave out this consumption. These rows also lack the assistant completion markers required here, so widening the type filter alone is insufficient. A completed 151,000-token compaction fixture produced no usage under the actual query.

Include these request rows with the appropriate completion condition in both the Codex and OpenCode scanners.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f3079cc. Both scanners accept type = 'compaction' with status = 'completed' as its own completion condition — confirmed on this machine that the assistant markers genuinely don't exist on those rows. The v1 branch stays assistant-only since that table has no such rows.

Note on magnitude here: 42 of my 43 local compaction rows carry no cost at all, so this changes almost nothing on older history and matters as newer builds record spend there. Covered by SQL-shape tests on both scanners plus a 151k-token counting test.

Comment on lines +238 to 240
AND COALESCE(json_extract(data,'$.model.providerID'), json_extract(data,'$.providerID')) = 'openai'
AND json_type(data,'$.cost') IN ('integer','real')
AND json_extract(data,'$.cost') = 0

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Zero Cost Does Not Prove OAuth for Earlier V2 History

On the published 1.18.25 experimental v2 path, the runner records zero cost for every completed request, including paid OpenAI API-key traffic. After switching that connection to ChatGPT OAuth, those historical paid requests satisfy this new filter and enter Codex subscription totals. The actual scanner reproduced the incorrect attribution.

This applies while using that earlier v2 database or retaining it as a channel sibling. New 2.0.3 API requests record costs correctly; an ordinary same-file upgrade normally replaces the older experimental table. Treat the ambiguous earlier records separately instead of assuming cost=0 proves subscription usage.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f3079cc with the credential-age bound: v2 openai rows older than the current OAuth credential's creation are excluded from Codex attribution; rows from its lifetime count. v1 rows priced paid traffic correctly and are unaffected, and file-based OAuth (no timestamp) keeps current behavior.

Two things I checked before choosing this over a schema marker: seq can't separate the eras (added June 2026, before the experimental window), and the experimental table is otherwise schema-identical, so credential age is the only per-row signal available. Known trade-off: reconnecting OAuth moves the bound forward and drops legit rows from the earlier OAuth window — undercounting those beats attributing paid history, but say the word if you'd rather bound it differently (e.g. earliest OAuth row rather than current).

Covered by a three-row timing test: pre-credential v2 excluded, post-credential v2 and pre-credential v1 both counted.

Comment on lines +73 to +75
for sql in [Self.credentialSQLGoKey, Self.credentialSQLOpencodeKey] {
if let key = credentialValue(from: sql) {
return key

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Keep Zen Spend Tiles When the Go Request Fails

Returning an ordinary Zen key here makes OpenCodeProvider.refresh() request Go meters before reading local history. A network or server failure returns an error immediately, hiding readable Zen spend tiles that previously worked offline. The provider check reproduced a network error and a missing Today tile solely because the Zen credential existed; the same local history appeared without that credential.

Keep the local history visible when this speculative Go request fails, while still reporting the API failure.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f3079cc. A failed Go request now logs and falls through to the local scan instead of returning early; the API error surfaces only when there is nothing else to show, so the empty-database loud-failure tests still pass unchanged. You're right that our opencode-key fallback widened this — previously only Go keys reached the request, now any Zen key does too, which is exactly why the degradation path matters.

Covered by two new provider tests: connection failure and 401 with local data both keep the Today tile with no error category.

…action, degraded Go

P1: 2.0.3 copies legacy rows into session_message under their original IDs,
so the union counted both. IDs ride through the projection and copies are
deduplicated across tables and channel databases, preferring the later
timestamp then the larger token count.

P2: credential reads take the current row first (active DESC, time_updated
DESC, id DESC, admitting NULL-flagged imports) instead of an arbitrary LIMIT 1
row. The live database now beats the retained auth.json file, which OpenCode 2
imports without deleting; the file stays the OpenCode 1 fallback.

P2: a failed Go meters request no longer hides local tiles. The failure is
logged and surfaces only when there is nothing else to show.

P2: completed compaction summaries count in both scanners, with their own
status-based completion condition - the assistant markers don't exist there.

P2: v2 openai rows older than the OAuth credential are excluded from Codex
attribution, since experimental builds zeroed every cost including paid
traffic. v1 rows priced paid traffic correctly and are unaffected; file-based
OAuth carries no timestamp and keeps current behavior.
@github-actions

Copy link
Copy Markdown

This PR has been inactive for 7 days. It will be closed in 3 days unless there is new activity. If this is still relevant, please comment or push an update.

@github-actions github-actions Bot added the stale No recent activity label Sep 23, 2026
@Adityacprtm

Copy link
Copy Markdown

Independent confirmation of the root cause, from a real OpenCode 2 + OpenUsage 0.7.12 setup (macOS):

Symptom: the OpenCode card shows OpenCode Go key was rejected. Log into OpenCode Go again. and the Go meters go stale.

What we found:

  • ~/.local/share/opencode/auth.json (mtime 2026-08-23) still held the old sk-… key.
  • The live credential in ~/.local/share/opencode/opencode.db → credential table is a newer active row created 2026-09-20 (integration_id='opencode-go', active=1), and its key uses the new oc_sk_… format (51 chars). The key in auth.json is exactly the active=0 row.
  • Logins on Sep 20 and Sep 24 only updated the DB; auth.json never changed — the retained file really does go stale, as described here.

Direct check with the exact call OpenUsage makes:

  • GET https://opencode.ai/zen/go/v1/usage with the stale file key → 401 {"type":"error","error":{"type":"AuthError","message":"Unauthorized"}}
  • same endpoint with the active DB key → 200 + { "usage": { rolling, weekly, monthly } }
  • POST /zen/go/v1/chat/completions with the stale key also → 401 Invalid credential, so the file key is genuinely dead, not endpoint-specific.

After syncing auth.json from the DB row, OpenUsage goes back to [refresh] opencode ok.

One detail worth calling out for the fallback: current Go keys are oc_sk_…, not sk-…, so a LIKE 'sk-%' fallback (as first suggested in #1124) would silently miss live credentials. The integration-scoped lookup here (opencode-go, then opencode) is the right approach, and "DB row wins over the retained file" matches what we observed.

Only gap on this end is mergeable_state: dirty (needs a rebase on main). Happy to re-verify against a live OpenCode 2 DB if useful.

@github-actions github-actions Bot removed the stale No recent activity label Sep 26, 2026
devin-ai-integration Bot added a commit that referenced this pull request Sep 30, 2026
OpenCode 2 logs to session_message and stores the Go key in each channel database's credential table, so the OpenCode card showed no spend and missed Go logins after the upgrade.

- Union message and session_message (reusing the Codex scanner's table probe), read nested model fields, fall back to summed token buckets, count completed compactions, and count migrated copies once by ID.
- Read the opencode-go key from the credential table; auth.json only stands in when no database has that table, so a v2 logout sticks.
- Keep local spend tiles when the Go meters request fails.

Based on #1243.

Co-authored-by: arpankanwer <arpankanwer98@gmail.com>
Co-Authored-By: Robin <rob@sunstory.com>
@devin-ai-integration

Copy link
Copy Markdown
Contributor

Thanks @arpankanwer! This PR found and diagnosed the OpenCode 2 session_message and credential gap.

Since #1284 already landed the Codex/auth side, I've moved the parts still needed onto current main in #1323. It keeps the message/session_message union, the migrated-row dedup, the completed compactions, the token-bucket fallback, the credential-table Go key, and keeping local tiles when the Go meters fail. You're credited as co-author on the commit. #1323 leaves out the sk-% key filter and the Codex/openAICredential changes; the PR description explains why.

robinebers added a commit that referenced this pull request Sep 30, 2026
* fix(opencode): read OpenCode 2 usage and Go credentials

OpenCode 2 logs to session_message and stores the Go key in each channel database's credential table, so the OpenCode card showed no spend and missed Go logins after the upgrade.

- Union message and session_message (reusing the Codex scanner's table probe), read nested model fields, fall back to summed token buckets, count completed compactions, and count migrated copies once by ID.
- Read the opencode-go key from the credential table; auth.json only stands in when no database has that table, so a v2 logout sticks.
- Keep local spend tiles when the Go meters request fails.

Based on #1243.

Co-authored-by: arpankanwer <arpankanwer98@gmail.com>
Co-Authored-By: Robin <rob@sunstory.com>

* test(opencode): pin compaction fixture to OpenCode v2.0.20 shapes

Co-authored-by: arpankanwer <arpankanwer98@gmail.com>
Co-Authored-By: Robin <rob@sunstory.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: arpankanwer <arpankanwer98@gmail.com>
Co-authored-by: Robin <rob@sunstory.com>
@robinebers robinebers closed this Sep 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

OpenCode 2 (session_message schema) not detected - local database unreadable + missing auth.json

5 participants