Skip to content

feat(agents): add OpenCode Go usage with V2 support - #12651

Closed
felixzsh wants to merge 21 commits into
omacom:quattrofrom
felixzsh:feat/opencode-go-usage-v2
Closed

felixzsh wants to merge 21 commits into
omacom:quattrofrom
felixzsh:feat/opencode-go-usage-v2

Conversation

@felixzsh

@felixzsh felixzsh commented Sep 20, 2026 •

Copy link
Copy Markdown
Contributor

Why this is a separate PR

This is a refreshed continuation of #6779, which was closed as similar to #8065 with an invitation
to submit it again explaining the differences.

It preserves the implementation and review feedback from #6779, including the percentage-scale
and stale-cache corrections reviewed by @dhh.

Compared with #8065, this implementation additionally supports current OpenCode V2 installations:

  • Reads both SQLite V1 (message) and V2 (session_message) schemas.
  • Deduplicates migrated messages by message ID.
  • Selects one active database without combining histories:
    OPENCODE_DB, opencode-v2.db, then opencode.db.
  • Supports V2 SQLite credentials, OPENCODE_API_KEY, legacy auth.json, and the Console session.
  • Correctly normalizes the official API percentages.
  • Supports both known endpoint response shapes.
  • Separates limits caches by account and validates cache freshness.
  • Handles authentication, entitlement, rate-limit, HTTP, and transport failures.
  • Preserves local statistics and offline operation when the API is unavailable.
  • Includes light/dark assets, manifest integration, documentation, and expanded failure coverage.

Getting the usage

Local token stats come from opencode's own SQLite database. Account limits come from OpenCode's
usage endpoint, trying each credential in turn and letting the endpoint pick the one it accepts:

  1. OPENCODE_API_KEY
  2. the opencode-go key in the SQLite database
  3. the opencode-go entry of auth.json
  4. the OpenCode Console session — what opencode v2 resolves the opencode-go provider through

API keys ask /zen/go/v1/usage; the Console session asks /inference/go/v1/usage with the
x-opencode-org-id header. An expired session reads as expired, not as a rejected sign-in.

Validation

  • Focused OpenCode Go collector tests pass.
  • Agent updater and panel tests pass.
  • CLI tests pass.
  • Python compilation and git diff --check pass.
  • Validated against a real OpenCode V2 database and live usage endpoint.

Supersedes #6779.

felixzsh and others added 11 commits August 12, 2026 18:13
Collects local token stats from opencode's own database (assistant
messages on the opencode-go provider) and the authoritative plan limits
from GET /zen/go/v1/usage — the same rolling 5-hour, weekly, and monthly
percentages the opencode.ai dashboard shows. Reads the key from
OPENCODE_GO_API_KEY or the opencode-go entry of ~/.local/share/opencode/
auth.json, and accepts both the deployed response shape and the merged PR
shape defensively. Probes are cached for 15s and kept as the answer of
record on transient failure, with retryAdvised for the panel.
The official opencode-go glyph as a white mark for dark surfaces and a
dark twin for light ones, following the codex.svg convention.
Fixture opencode database rows (today, older, wrong-provider, zero-token),
both endpoint response shapes, percent scaling, rate-limited windows, key
precedence, and failure paths keep local stats.
The hero meta line is the plan, never the product: claude shows its
subscription tier and codex its planType there. "OpenCode Go" duplicated
the Go plan, so the display name is now just OpenCode and tierLabel keeps
the Go plan. Tests pin both fields.
The client swaps never exercise the real transport, so GoUsageClient.probe
is now driven against a stubbed urllib.request.urlopen, the way the claude
limits test stubs it: the request path and its bearer, accept, and
user-agent headers are asserted, the deployed response shape is decoded,
and a rejected key, a server status, and a transport failure each map to
their distinct GoUsageError.
The per-value heuristic rendered 1% as a full meter and 0.5% as half of
one: any value above 1 was divided by 100 while its neighbors below 1
were not, so one payload could mix scales. Detect the scale across the
whole payload the way the claude collector does — any value reaching 1
means percentages — and normalize every window with it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A probe failure fell back to the cached limits forever, so a window
whose reset time had long passed kept a dead meter on the panel, and a
rejected API key hid behind that silent fallback with no status at all.
Filter the cache through the same window-expiry check the claude
collector uses, flag auth and transport failures on the error itself,
and answer a rejected key with a "Sign-in rejected" status and the
remedy. A missing key now keeps still-open windows under "Waiting for
auth" too, matching claude's fallback handling.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The database holds every historical message, JSON included, so the scan now
filters in SQL (LIKE gates plus json_valid/json_extract with providerID
opencode-go) instead of parsing tens-of-MB blobs in Python. The stats cache
gains a versioned envelope stamped with the scan date, negative-age and
cross-day misses, and never persists a scan cut short by a database error;
cache-layer failures degrade to a direct scan instead of a crash.
A versioned, dated envelope on first scan, corrupt-cache recovery,
limits-only reuse with a force bypass, dated and future-dated misses,
malformed opencode rows that must not abort the scan, an interrupted scan
that must never be cached, and an unwritable cache root that falls back to
a direct scan.
@felixzsh
felixzsh force-pushed the feat/opencode-go-usage-v2 branch from 9583007 to 8b7bae6 Compare September 20, 2026 17:59
opencode v2 resolves the opencode-go provider through the `opencode`
(OpenCode Console) integration and moved usage to /inference/go, so all
three stored keys went dead and the panel told a signed-in user to run
`opencode auth login`.

Try every way in and let the endpoint pick: OPENCODE_API_KEY, the SQLite
opencode-go key, the auth.json key, then the Console OAuth session on its
own endpoint carrying the workspace header — and only when that id is
known, since a wrong one is a hard 403. An expired session now says so
instead of claiming the sign-in was rejected.
@felixzsh
felixzsh force-pushed the feat/opencode-go-usage-v2 branch from 8b7bae6 to 8e82bf3 Compare September 22, 2026 21:54
@llstrk

llstrk commented Sep 24, 2026

Copy link
Copy Markdown

Automated AI review

Community review: Independent automated community review, unaffiliated with the Omarchy team, intended to help prepare PRs for their review.

Verified: the collector reads V1 message and V2 session_message rows in the shapes OpenCode stores them. Deduplication by message ID is correct for OpenCode's in-place V1 to V2 migration, which keeps message IDs. The opencode-go credential and auth.json formats match OpenCode. /zen/go/v1/usage responses are handled as the public server source defines them: 401, 403 EntitlementError, and integer percents from 0 to 100, so dividing by 100 is right. Limits caches are per credential and reject future-dated or reset windows, and local statistics survive auth, entitlement and network failures. The record, manifest key and icons follow the existing agents panel conventions, and the PR's test passes (13 of 13) in an isolated run.

Four issues remain. The first affects which database is read. The others affect local token totals and how limits behave when the endpoint fails. Bare :line references below point into bin/omarchy-agent-usage-opencode-go.

A leftover opencode-v2.db hides the live opencode.db

opencode_db_path() returns opencode-v2.db whenever that file exists (bin/omarchy-agent-usage-opencode-go:92-103), and credentials come from the same file. In OpenCode v2.0.14, packages/cli/src/database-path.ts:4-13 writes opencode.db for the latest, dev, beta, next and prod channels. A file named opencode-v2.db only comes from a build whose channel was literally v2, and current OpenCode does not read or migrate that file.

~/.local/share/opencode/              collector reads   OpenCode writes
  opencode-v2.db  (old v2-channel)    yes               no
  opencode.db     (current releases)  no                yes

Synthetic reproduction: a stale opencode-v2.db (old rows, expired Console token) sits next to a live opencode.db (500 tokens today, valid token). The collector reported 0 tokens today and "OpenCode Console session expired". Pointed at opencode.db instead, it reported 500 tokens and three limit windows.

Impact: on a machine that ever ran a v2-channel build, local stats stop updating and limits use stale credentials. The effect does not wear off, because nothing removes the old file. How many machines have that file depends on install history.

Suggested change: follow OpenCode's own rule: OPENCODE_DB, then opencode.db, then opencode-v2.db only when opencode.db is absent (or pick the most recently written file). Update the README sentence that places V2 data in opencode-v2.db, and the test that pins the current order.

V2 local token totals differ from OpenCode's own stats

V2 rows OpenCode stats.ts Collector
Fork copies (projector.ts:130-222: new IDs, same data and time_created) Excluded: fork_session_id IS NULL OR message.time_created >= session.time_created (stats.ts:126-148) Counted again, because deduplication is by ID only. Synthetic fork: 2200 tokens vs 1100
compaction rows with model and tokens Added through UsageRecorded events (stats.ts:285-319) Dropped: the V2 query reads type = 'assistant' only (:210-216). Synthetic case: 110 counted, 52000 compaction tokens ignored

OpenCode source cited from v2.0.14.

Impact: on V2, every fork adds its copied history again on the original days, and compaction usage is missing. The V1 path does count V1's compaction message, which is stored as an assistant message.

Suggested change: join session_v2 and apply the same fork filter as stats.ts, and include compaction rows that carry tokens for an opencode-go model.

429 and 5xx responses start a 30-second re-probe loop

probe() marks HTTP 429, any status of 500 or above, and empty or unparseable payloads as retryable. collect_limits then writes retryAdvised: true (:542-577, :736-748). The PR's README describes this as intended ("transport and transient server failures ask the panel to retry"). The panel side is built for a narrower case. Main.qml documents retryAdvised as the signal from a collector that "could not reach its limits endpoint at all", and answers it with a one-shot --limits-only rerun 30 seconds later, re-armed by each new record. The Claude collector sets the flag only for transport failures (bin/omarchy-agent-usage-claude:711-740). This collector's own comment at lines 733-736 also says a server that answered with an error status should not be pestered.

Impact: during a sustained rate limit or outage, the endpoint is probed about every 30 seconds instead of on the normal 15-minute refresh. Under a 429, this works against the rate limit.

Suggested change: set retryAdvised only for transport failures, or add a backoff for 429 and 5xx responses.

Open cached windows are lost on a forced refresh or after several refused credentials

The README says "A failed probe keeps open cached windows" (shell/plugins/agents/README.md:94-95). Two paths do not do that:

  • Forced refresh: the panel's refresh button passes --force, and fallback = [] if force else ... (:711) skips the cache entirely. Offline or during a 5xx, pressing refresh blanks the meters. This matches the README's "--force bypasses both caches", but the Claude collector handles it differently: a forced run skips only the reuse window and still shows open cached windows when the probe fails.
  • Several credentials: each refused candidate (401 or entitlement 403) overwrites the saved refused result (:737-739), and only the last one's fallback reaches the record (:759-760). If an earlier refused credential has open cached windows and the last one has none, no limits are shown. A later transport failure has the same effect (:740-747). The result depends on candidate order.

Impact: the meters show "limits unavailable" in cases where the cache still holds valid windows.

Suggested change: load cached windows before checking force, as the Claude collector does, and keep the first non-empty refused fallback instead of the last. Alternatively, narrow the README wording.

Optional test improvement: the first collector call in test/shell.d/agent-usage-opencode-go-scanner-test.sh clears OPENCODE_API_KEY but not OPENCODE_DB. In an isolated run with OPENCODE_DB pointed at a synthetic database, that first assertion failed. With a real database exported, the test would read local credentials and could send them to opencode.ai. Adding OPENCODE_DB="" to that call closes the gap.

Optional test improvement: the 15-second reuse assertion also passes with reuse disabled, because a failed probe falls back to the same cached window. Asserting the fake client's request count would tell the two apart.

Documentation: the edited paragraph in manual/17-ai.md is re-wrapped at about 80 columns. The repository's AGENTS.md asks for full lines in manual/.


Review information

Test scope: Source review of the pinned head against OpenCode source (tags v2.0.14 and v1.18.32, and the public Console server code for /zen/go/v1/usage). The PR test and synthetic scenario probes ran in a network-free sandbox with synthetic databases and credentials. No live endpoint, real OpenCode installation or panel UI was tested. The Console path /inference/go/v1/usage and its x-opencode-org-id header do not appear in the public OpenCode source that was inspected, so they rest on the author's live validation.

AI process: Opus 5.5 Medium coordination and synthesis, independent Opus 5.5 Xhigh and GPT 6 Sol Xhigh technical assessments with targeted cross-checks of each other's claims, Opus 5.5 Medium editorial check.

Opt out: To stop receiving these reviews, reply to this comment saying so.

…ed servers

opencode-v2.db only comes from a build whose channel was literally "v2"; current releases write opencode.db, so a stale v2 file shadowed the live database. Prefer OPENCODE_DB, then opencode.db, and keep the v2 name only as a fallback.

Also stop marking 429/5xx responses as retryable, which put the panel on a 30-second re-probe loop for a server that had already answered. Document both in the agents README and reflow the manual paragraph to a full line.
A forced refresh discarded the cached windows before probing, so an offline or 5xx probe blanked the meters even though the cache still held open windows. Load the cache in every case and keep it as the answer of record; --force still bypasses the 15-second reuse window.

When several credentials are refused, the last one overwrote the saved fallback, so an earlier credential with open windows could be hidden. Keep the first refusal that still has windows.
V2 stores compaction usage in its own rows and copies a source session's messages into a fork under new IDs, so the collector counted copied history again and dropped compaction tokens. Read compaction rows too and count a fork only from its creation time on, matching OpenCode's own statistics.
@dhh

dhh commented Oct 2, 2026

Copy link
Copy Markdown
Contributor

Thanks! Your V2 dedupe, OPENCODE_DB, Console session login, alternate payload shape, and per-key cache are folded into the OpenCode collector in #14040 (credited there). Closing in favor of that.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants