fix(opencode): support OpenCode 2 session_message schema and credential table - #1243
arpankanwer wants to merge 7 commits into
Conversation
…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.
There was a problem hiding this comment.
🟡 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_messagerows in addition to legacymessagerows, with coalesced JSON paths and token-total fallback logic. - Update
OpenCodeAuthStoreto read credentials fromauth.jsonfirst, then fall back to the SQLitecredentialtable 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.
| 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) |
There was a problem hiding this comment.
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.
| 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' |
There was a problem hiding this comment.
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.tsbuilds an empty database withschema.up(tx)fromschema.gen.ts, which creates bothmessage(line 128) andsession_message(line 170). - An upgraded database keeps both:
messageis created by the earliest migration and no migration drops it (the onlyDROP TABLEin the v2 migration issession_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.
|
@codex review |
There was a problem hiding this comment.
💡 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".
| private static let credentialSQLAnyKey = | ||
| "SELECT json_extract(value,'$.key') FROM credential WHERE json_extract(value,'$.key') LIKE 'sk-%' LIMIT 1;" |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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.
| } catch { | ||
| continue |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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.
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.
|
@codex review |
There was a problem hiding this comment.
💡 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 } |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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 )" |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
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.
| 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;" |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
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.
| 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 |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
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.
| 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') |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
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.
| 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 |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
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.
| for sql in [Self.credentialSQLGoKey, Self.credentialSQLOpencodeKey] { | ||
| if let key = credentialValue(from: sql) { | ||
| return key |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
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.
|
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. |
|
Independent confirmation of the root cause, from a real OpenCode 2 + OpenUsage 0.7.12 setup (macOS): Symptom: the OpenCode card shows What we found:
Direct check with the exact call OpenUsage makes:
After syncing One detail worth calling out for the fallback: current Go keys are Only gap on this end is |
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>
|
Thanks @arpankanwer! This PR found and diagnosed the OpenCode 2 Since #1284 already landed the Codex/auth side, I've moved the parts still needed onto current |
* 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>
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
messagetable and the auth store only readsauth.json. This adds OpenCode 2 support: both OpenCode scanners unionmessage+session_messagewith coalesced JSON paths, and credentials fall back fromauth.jsonto the SQLitecredentialtable.What was happening
OpenCode 2 changed local storage in two places:
message(role,providerID,modelID,tokens.total) →session_message(columnstype,seq,data;type='assistant'replaces$.role, with$.model.providerID,$.model.id, andtokens.input/output/reasoning/cache.*— nototal)~/.local/share/opencode/auth.json→ the SQLitecredentialtable (integration_id='opencode-go',valueJSON{"key":"sk-..."})On a v2 database the scan hits
no such table: message(or reads only stale pre-upgrade rows) andgoAPIKey()returns nil, so the card reports an unreadable database and drops all Go meters.Measured on a migrated local
~/.local/share/opencode/opencode.db:message(v1)session_message(v2)Both tables persist after migration, so a v1-only read roughly halves the spend tiles and reports
0for any day after the upgrade.What this changes
OpenCodeUsageScanner—dataSQL/probeSQLunion both tables with coalesced JSON paths:COALESCE($.model.providerID,$.providerID),COALESCE($.model.id,$.modelID), andCOALESCE($.tokens.total, input+output+reasoning+cache.read+cache.write).messagekeeps its$.role='assistant'filter;session_messageacceptsassistantplus 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 ownstatus-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 livecredentialtable first (OpenCode 2 importsauth.jsonwithout deleting it, so a retained file key goes stale; the file stays the OpenCode 1 fallback), still throwingcredentialsUnreadableon a malformed file. Every lookup takes the current row first (active DESC, time_updated DESC, id DESC, admitting NULL-flagged imports) instead of an arbitraryLIMIT 1row. Go keys stay scoped toopencode-go/opencode.OpenCodePaths— owns theOpenCodeMessageTablesset and thesqlite_masterprobe 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
auth.jsonis still tried first and v1 rows still count.opencode-go/opencodeon 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).sqlite_masteronce 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.Tests
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()andhasCodexOAuth()(including auth.json winning when both exist, and an API-key credential not counting as OAuth), both fallback queries naming their integration (no unscopedLIKE 'sk-%'), the best-effort nil contract on credential DB failures, a v2 assertion on the Codex scanner SQL, and hermeticdatabasePaths: { [] }on the auth-store tests so they can no longer read this machine's real databases. The existingtestQuerySelectsOnlyCompletedOpenAIRowsasserted the substringproviderID') = '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:
Before/after through the local HTTP API on the same machine:
Screenshots
Not applicable — no visual change.