diff --git a/bin/omarchy-agent-usage-opencode-go b/bin/omarchy-agent-usage-opencode-go new file mode 100755 index 00000000000..e3c84fba207 --- /dev/null +++ b/bin/omarchy-agent-usage-opencode-go @@ -0,0 +1,859 @@ +#!/usr/bin/python3 +# omarchy:summary=Print the OpenCode Go usage record as JSON +# omarchy:args=[--force] [--limits-only] +# omarchy:hidden=true +"""Collect OpenCode Go usage into one display-ready JSON record. + +Local stats come from opencode's own database, counting assistant messages +that ran on the opencode-go provider; the authoritative plan limits come from +OpenCode's official usage endpoint (GET /zen/go/v1/usage), which reports the +rolling 5-hour, weekly, and monthly windows as percentages with reset times — +the same numbers the opencode.ai dashboard shows. The agents panel only ever +reads the JSON this prints; it never talks to the database or the endpoint. +""" + +from __future__ import annotations + +import argparse +import datetime as dt +import fcntl +import hashlib +import json +import math +import os +import sqlite3 +import sys +import tempfile +import time +import urllib.error +import urllib.request +from pathlib import Path +from typing import Any, NamedTuple + +AGENT_ID = "opencode-go" +AGENT_NAME = "OpenCode" +AUTH_HELP = "Run `opencode auth login` and subscribe to OpenCode Go, or set OPENCODE_API_KEY." +API_BASE_URL = "https://opencode.ai" +USAGE_PATH = "/zen/go/v1/usage" +CONSOLE_USAGE_PATH = "/inference/go/v1/usage" +CONSOLE_ORG_HEADER = "x-opencode-org-id" +CONSOLE_EXPIRED_HELP = ( + "OpenCode's Console session has expired — run `opencode auth login` to refresh it." +) +PROBE_MIN_INTERVAL_SECONDS = 15 + + +def data_home() -> Path: + return Path(os.environ.get("XDG_DATA_HOME") or (Path.home() / ".local" / "share")) + + +def cache_root() -> Path: + root = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "omarchy" / "agent-usage" + root.mkdir(parents=True, exist_ok=True) + return root + + +def date_string(value: dt.date) -> str: + return value.strftime("%Y-%m-%d") + + +def recent_date_strings() -> list[str]: + today = dt.datetime.now().date() + return [date_string(today - dt.timedelta(days=offset)) for offset in range(6, -1, -1)] + + +def local_date_string() -> str: + return date_string(dt.datetime.now().date()) + + +def number(value: Any) -> int: + try: + n = float(value or 0) + return max(0, round(n)) if n == n else 0 + except Exception: + return 0 + + +def empty_bucket() -> dict[str, int]: + return { + "inputTokens": 0, + "outputTokens": 0, + "cacheReadInputTokens": 0, + "cacheCreationInputTokens": 0, + } + + +# ---------------------------------------------------------------- local scan +# +# OpenCode has both the original message table and the newer session_message +# table. Both carry assistant usage, but a migrated message can appear in both, +# so rows are deduplicated by message id after filtering to opencode-go. + + +def opencode_db_path() -> Path | None: + root = data_home() / "opencode" + configured = os.environ.get("OPENCODE_DB", "").strip() + if configured: + configured_path = Path(configured).expanduser() + return (configured_path if configured_path.is_absolute() else root / configured_path).resolve() + normal = root / "opencode.db" + if normal.is_file(): + return normal + # Only a build whose channel was literally "v2" wrote this name; current + # releases use opencode.db, so it remains a fallback for older installs. + legacy = root / "opencode-v2.db" + return legacy if legacy.is_file() else None + + +def parse_usage_message( + raw: str, legacy: bool, today: str +) -> dict[str, Any] | None: + try: + entry = json.loads(raw) + except Exception: + return None + if not isinstance(entry, dict): + return None + + if legacy: + if entry.get("role") != "assistant": + return None + provider = entry.get("providerID") + model_id = entry.get("modelID") + else: + model_info = entry.get("model") + if not isinstance(model_info, dict): + return None + provider = model_info.get("providerID") + model_id = model_info.get("id") + if str(provider or "") != "opencode-go": + return None + + tokens = entry.get("tokens") + if not isinstance(tokens, dict): + return None + cache = tokens.get("cache") or {} + if not isinstance(cache, dict): + cache = {} + input_tokens = number(tokens.get("input")) + output_tokens = number(tokens.get("output")) + number(tokens.get("reasoning")) + cache_read = number(cache.get("read")) + cache_write = number(cache.get("write")) + total = input_tokens + output_tokens + cache_read + cache_write + if total <= 0: + return None + + time_info = entry.get("time") or {} + if not isinstance(time_info, dict): + return None + created = number(time_info.get("created")) + day = ( + dt.datetime.fromtimestamp(created / 1000).strftime("%Y-%m-%d") + if created > 0 + else today + ) + model = str(model_id or "opencode").rstrip("/").split("/")[-1] + return { + "day": day, + "model": model or "opencode", + "input": input_tokens, + "output": output_tokens, + "cacheRead": cache_read, + "cacheWrite": cache_write, + "total": total, + } + + +def scan_opencode_db(db: Path) -> tuple[dict[str, Any], bool]: + """Returns (stats, complete): a scan cut short by a database error still + contributes what it read, but its caller must not cache it as the whole + story.""" + today = local_date_string() + recent_dates = recent_date_strings() + recent = {day: {"date": day, "messageCount": 0} for day in recent_dates} + sessions: set[str] = set() + active_days: set[str] = set() + today_sessions: set[str] = set() + today_tokens: dict[str, int] = {} + usage_by_model: dict[str, dict[str, int]] = {} + prompts = 0 + today_prompt_count = 0 + today_token_total = 0 + + try: + # Read-only: opencode may be writing right now. + conn = sqlite3.connect(db.resolve().as_uri() + "?mode=ro", uri=True, timeout=2) + except sqlite3.Error: + return empty_stats(), False + complete = True + try: + conn.execute("PRAGMA query_only = ON") + tables = { + name + for (name,) in conn.execute( + "SELECT name FROM sqlite_master WHERE type = 'table'" + ) + } + if not tables.intersection({"message", "session_message"}): + return empty_stats(), False + + seen_message_ids: set[str] = set() + + def scan_table(legacy: bool) -> None: + nonlocal prompts, today_prompt_count, today_token_total + if legacy: + query = ( + "SELECT id, session_id, data FROM message" + " WHERE data LIKE '%\"role\"%:%\"assistant\"%'" + " AND data LIKE '%\"providerID\"%:%\"opencode-go\"%'" + " AND CASE WHEN json_valid(data) THEN json_extract(data, '$.role') END = 'assistant'" + " AND CASE WHEN json_valid(data) THEN " + "json_extract(data, '$.providerID') END = 'opencode-go'" + ) + else: + # V2 records usage in assistant and compaction rows. Always alias the + # table so the optional fork join can qualify its columns. + source = "session_message m" + fork_filter = "" + if "session_v2" in tables: + # A fork copies the source session's messages under new IDs; OpenCode + # counts only messages created at or after the fork session. + source += " LEFT JOIN session_v2 s ON s.id = m.session_id" + fork_filter = ( + " AND (s.fork_session_id IS NULL OR m.time_created >= s.time_created)" + ) + query = ( + "SELECT m.id, m.session_id, m.data FROM " + source + + " WHERE m.type IN ('assistant', 'compaction')" + " AND m.data LIKE '%\"providerID\"%:%\"opencode-go\"%'" + " AND CASE WHEN json_valid(m.data) THEN " + "json_extract(m.data, '$.model.providerID') END = 'opencode-go'" + + fork_filter + ) + + for message_id, session_id, raw in conn.execute(query): + try: + usage = parse_usage_message(raw, legacy, today) + except Exception: + continue + if usage is None or str(message_id) in seen_message_ids: + continue + seen_message_ids.add(str(message_id)) + day = usage["day"] + model = usage["model"] + input_tokens = usage["input"] + output_tokens = usage["output"] + cache_read = usage["cacheRead"] + cache_write = usage["cacheWrite"] + total = usage["total"] + session_key = "opencode:" + str(session_id) + sessions.add(session_key) + active_days.add(day) + prompts += 1 + + bucket = usage_by_model.setdefault(model, empty_bucket()) + bucket["inputTokens"] += input_tokens + bucket["outputTokens"] += output_tokens + bucket["cacheReadInputTokens"] += cache_read + bucket["cacheCreationInputTokens"] += cache_write + + if day in recent: + # recentDays.messageCount is actually a token total, despite the + # legacy name shared by synced snapshots. + recent[day]["messageCount"] += total + if day == today: + today_prompt_count += 1 + today_sessions.add(session_key) + today_token_total += total + today_tokens[model] = today_tokens.get(model, 0) + total + + if "session_message" in tables: + scan_table(False) + if "message" in tables: + scan_table(True) + except sqlite3.Error: + complete = False + finally: + conn.close() + + return { + "todayPrompts": today_prompt_count, + "todaySessions": len(today_sessions), + "todayTotalTokens": today_token_total, + "todayTokensByModel": today_tokens, + "recentDays": [recent[day] for day in recent_dates], + "totalPrompts": prompts, + "totalSessions": len(sessions), + "activeDays": len(active_days), + "activeDates": sorted(active_days), + "modelUsage": usage_by_model, + }, complete + + +def empty_stats() -> dict[str, Any]: + return { + "todayPrompts": 0, + "todaySessions": 0, + "todayTotalTokens": 0, + "todayTokensByModel": {}, + "recentDays": [{"date": day, "messageCount": 0} for day in recent_date_strings()], + "totalPrompts": 0, + "totalSessions": 0, + "activeDays": 0, + "activeDates": [], + "modelUsage": {}, + } + + +# The same freshness contract as the other collectors: --limits-only promises +# to reuse recent local stats, and a big opencode history walked on every +# panel open would break that promise. + + +def scan_cache_paths(db: Path) -> tuple[Path, Path]: + digest = hashlib.sha1(str(db.resolve()).encode("utf-8")).hexdigest()[:16] + root = cache_root() + return root / f"opencode-go-scan-{digest}.json", root / f"opencode-go-scan-{digest}.lock" + + +def read_fresh_json(path: Path, max_age_seconds: float) -> dict[str, Any] | None: + if max_age_seconds <= 0 or not path.exists(): + return None + try: + # A negative age means the mtime is in the future: the clock moved + # backwards since the write, so the cache's freshness cannot be trusted. + age = time.time() - path.stat().st_mtime + if 0 <= age <= max_age_seconds: + return json.loads(path.read_text(encoding="utf-8")) + except Exception: + return None + return None + + +def write_json(path: Path, payload: dict[str, Any]) -> None: + handle_fd, tmp_name = tempfile.mkstemp(dir=path.parent, prefix=path.name + ".", suffix=".tmp") + tmp = Path(tmp_name) + try: + with os.fdopen(handle_fd, "w", encoding="utf-8") as handle: + handle.write(json.dumps(payload, separators=(",", ":"), sort_keys=True) + "\n") + tmp.chmod(0o644) + tmp.replace(path) + except BaseException: + tmp.unlink(missing_ok=True) + raise + + +def read_cached_stats(cache_file: Path, max_age_seconds: float) -> dict[str, Any] | None: + cached = read_fresh_json(cache_file, max_age_seconds) + if not isinstance(cached, dict) or cached.get("schemaVersion") != 2: + return None + # today* fields only mean "today" on the day they were scanned: a cache + # from another local date (midnight passed, or the clock moved) is a miss, + # not merely old, whatever its mtime says. + if cached.get("scanDate") != local_date_string(): + return None + stats = cached.get("stats") + if not isinstance(stats, dict): + return None + required = ("todayPrompts", "todayTotalTokens", "recentDays", "activeDates", "modelUsage") + if not all(key in stats for key in required): + return None + return stats + + +def write_cached_stats(cache_file: Path, stats: dict[str, Any]) -> None: + write_json(cache_file, {"schemaVersion": 2, "scanDate": local_date_string(), "stats": stats}) + + +def cached_scan(db: Path, max_age_seconds: float) -> dict[str, Any]: + # The cache is a pure optimization: any cache-layer failure (unwritable + # root, lock errors, disk full) degrades to a direct scan, never a crash. + try: + return _cached_scan(db, max_age_seconds) + except Exception as exc: + print( + f"omarchy-agent-usage-opencode-go: cache unavailable ({exc}); scanning directly", + file=sys.stderr, + ) + stats, _ = scan_opencode_db(db) + return stats + + +def _cached_scan(db: Path, max_age_seconds: float) -> dict[str, Any]: + cache_file, lock_file = scan_cache_paths(db) + + cached = read_cached_stats(cache_file, max_age_seconds) + if cached is not None: + return cached + + with lock_file.open("w") as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + cached = read_cached_stats(cache_file, max_age_seconds) + if cached is not None: + return cached + stats, complete = scan_opencode_db(db) + # An interrupted scan still serves this run, but caching it would + # suppress the missing usage for every reader until the cache expires. + if complete: + write_cached_stats(cache_file, stats) + return stats + + +# ------------------------------------------------------------------ limits +# +# The usage endpoint is opencode's own account dashboard data: rolling 5-hour, +# weekly, and monthly windows as a used percentage and a reset time. Limits +# are per-account and authoritative, so the panel's meters show the same +# numbers the website does — no local estimate that would undercount sessions +# run on other machines or in other harnesses. + + +class Credential(NamedTuple): + """One way of answering who is asking for OpenCode Go usage.""" + + token: str + path: str + headers: dict[str, str] + expired: bool = False + + +def read_auth_json(path: Path) -> str: + try: + parsed = json.loads(path.read_text()) + except (OSError, json.JSONDecodeError): + return "" + entry = parsed.get("opencode-go") if isinstance(parsed, dict) else None + if not isinstance(entry, dict): + return "" + return str(entry.get("key") or "").strip() + + +def read_sqlite_credential(db: Path) -> str: + try: + conn = sqlite3.connect(db.resolve().as_uri() + "?mode=ro", uri=True, timeout=2) + except sqlite3.Error: + return "" + try: + rows = conn.execute( + "SELECT value FROM credential WHERE integration_id = ? AND active = 1 " + "ORDER BY active DESC, time_updated DESC", + ("opencode-go",), + ) + for (raw,) in rows: + try: + value = json.loads(raw) + except (TypeError, json.JSONDecodeError): + continue + if isinstance(value, dict) and value.get("type") == "key": + key = str(value.get("key") or "").strip() + if key: + return key + except sqlite3.Error: + return "" + finally: + conn.close() + return "" + + +def read_console_credential(db: Path) -> Credential | None: + """Read the OpenCode Console session opencode v2 stores in SQLite. + + The opencode-go provider resolves its own credentials through the `opencode` + integration, so a Console session answers as an OpenCode Go credential — on + Console's own endpoint, carrying the workspace id Console traffic is scoped + to. A wrong workspace is a hard 403, so the header only goes out when the + id is actually there. + """ + try: + conn = sqlite3.connect(db.resolve().as_uri() + "?mode=ro", uri=True, timeout=2) + except sqlite3.Error: + return None + try: + rows = conn.execute( + "SELECT value FROM credential WHERE integration_id = ? AND active = 1 " + "ORDER BY active DESC, time_updated DESC", + ("opencode",), + ) + for (raw,) in rows: + try: + value = json.loads(raw) + except (TypeError, json.JSONDecodeError): + continue + if not isinstance(value, dict) or value.get("type") != "oauth": + continue + token = str(value.get("access") or "").strip() + if not token: + continue + metadata = value.get("metadata") + org = str(metadata.get("orgID") or "").strip() if isinstance(metadata, dict) else "" + headers = {CONSOLE_ORG_HEADER: org} if org else {} + expired = 0 < number(value.get("expires")) <= time.time() * 1000 + return Credential(token, CONSOLE_USAGE_PATH, headers, expired) + except sqlite3.Error: + return None + finally: + conn.close() + return None + + +def credential_candidates(auth_path: Path, db: Path | None = None) -> list[Credential]: + # An explicit key beats whatever opencode happens to be signed in with, and + # the Console session is the implicit one, so it comes last. Candidates are + # only skipped when they are absent: which one the endpoint accepts is not + # knowable from here, and a stale key in front must not hide a live one. + tokens = [str(os.environ.get("OPENCODE_API_KEY", "")).strip()] + if db is not None: + tokens.append(read_sqlite_credential(db)) + tokens.append(read_auth_json(auth_path)) + candidates = [Credential(token, USAGE_PATH, {}) for token in tokens if token] + if db is not None: + console = read_console_credential(db) + if console is not None: + candidates.append(console) + return candidates + + +class GoUsageError(Exception): + def __init__( + self, + message: str, + auth: bool = False, + entitlement: bool = False, + transport: bool = False, + retry: bool = False, + ): + super().__init__(message) + self.auth = auth + self.entitlement = entitlement + self.transport = transport + self.retry = retry + + +class GoUsageClient: + def __init__( + self, + token: str, + base_url: str = API_BASE_URL, + path: str = USAGE_PATH, + headers: dict[str, str] | None = None, + ): + self.token = token + self.base_url = base_url.rstrip("/") + self.path = path + self.headers = headers or {} + + def probe(self) -> dict[str, Any]: + # Cloudflare in front of opencode.ai blocks the default Python-urllib + # signature (Error 1010); any recognizable agent passes, so identify the + # probe rather than borrow a browser's. + request = urllib.request.Request( + self.base_url + self.path, + headers={ + "Authorization": "Bearer " + self.token, + "Accept": "application/json", + "User-Agent": "omarchy-agent-usage/1", + **self.headers, + }, + ) + try: + with urllib.request.urlopen(request, timeout=10) as response: + payload = json.loads(response.read().decode("utf-8", errors="replace")) + if not isinstance(payload, dict) or not payload: + raise GoUsageError( + "OpenCode's usage endpoint returned an empty payload", retry=True + ) + return payload + except urllib.error.HTTPError as error: + if error.code == 401: + raise GoUsageError("OpenCode Go rejected the API key", auth=True) + body = "" + if error.code == 403: + try: + body = error.read().decode("utf-8", errors="replace") + except Exception: + pass + if "EntitlementError" in body: + raise GoUsageError( + "OpenCode Go subscription required", entitlement=True + ) + retry = error.code == 429 or error.code >= 500 + raise GoUsageError( + f"OpenCode's usage endpoint returned status {error.code}", retry=retry + ) + except (urllib.error.URLError, OSError, TimeoutError) as error: + raise GoUsageError("Could not reach OpenCode's usage endpoint", transport=True) from error + except json.JSONDecodeError as error: + raise GoUsageError( + "OpenCode returned an invalid usage response", retry=True + ) from error + + +# The live endpoint nests the windows under "usage" with "percent" and +# "resetsAt"; the merged PR shape spelled them "Usage" with +# "usagePercent" and "resetInSec". Accept both so the collector does not +# depend on which deployment shape is in front of it. +WINDOW_LABELS = { + "rolling": "Session (5-hour)", + "weekly": "Weekly (7-day)", + "monthly": "Monthly (30-day)", +} + + +def parse_percent(value: Any) -> float: + try: + return float(str(value).strip()) + except Exception: + return float("nan") + + +def normalize_percent(value: Any) -> float | None: + n = parse_percent(value) + if not (n >= 0): + return None + return min(1.0, n / 100.0) + + +def reset_from_seconds(value: Any) -> str: + seconds = number(value) + if seconds <= 0: + return "" + return (dt.datetime.now(dt.timezone.utc) + dt.timedelta(seconds=seconds)).isoformat() + + +def parse_usage_payload(payload: dict[str, Any]) -> list[dict[str, Any]]: + usage = payload.get("usage") + usage = usage if isinstance(usage, dict) else payload + windows: list[tuple[str, dict[str, Any]]] = [] + for key, label in WINDOW_LABELS.items(): + entry = usage.get(key) if isinstance(usage, dict) else None + if not isinstance(entry, dict): + entry = payload.get(key + "Usage") + if not isinstance(entry, dict): + continue + windows.append((label, entry)) + out: list[dict[str, Any]] = [] + for label, entry in windows: + percent = normalize_percent(entry.get("percent", entry.get("usagePercent"))) + if percent is None: + continue + # A rate-limited window is at 100% and its reset is what matters. + if str(entry.get("status") or "") == "rate-limited": + percent = 1.0 + resets_at = str(entry.get("resetsAt") or "").strip() + resets_at = resets_at or reset_from_seconds(entry.get("resetInSec")) + out.append({"label": label, "percent": percent, "resetsAt": resets_at}) + return out + + +# A cached percentage outlives the probe that measured it, but only until its +# window rolls over: once a window has reset, the figure describes a period +# that is over, and a stale 78% would misreport an allowance that is now +# untouched. A window with no reset time, or one that will not parse, is kept +# — an unreadable timestamp is no reason to throw away a real number. +def limit_window_open(entry: dict[str, Any], now: dt.datetime) -> bool: + raw = str(entry.get("resetsAt") or "") + if raw == "": + return True + try: + resets_at = dt.datetime.fromisoformat(raw.replace("Z", "+00:00")) + except Exception: + return True + if resets_at.tzinfo is None: + resets_at = resets_at.replace(tzinfo=dt.timezone.utc) + return resets_at > now + + +def usable_cached_limits(cached: dict[str, Any]) -> list[dict[str, Any]]: + if cached_fetched_at(cached) is None: + return [] + entries = cached.get("limits") + if not isinstance(entries, list): + return [] + now = dt.datetime.now(dt.timezone.utc) + return [entry for entry in entries if isinstance(entry, dict) and limit_window_open(entry, now)] + + +def cached_fetched_at(cached: dict[str, Any]) -> float | None: + try: + fetched_at_ms = float(cached.get("fetchedAtMs")) + except (TypeError, ValueError): + return None + if not math.isfinite(fetched_at_ms) or fetched_at_ms <= 0: + return None + if fetched_at_ms > time.time() * 1000: + return None + return fetched_at_ms / 1000 + + +def limits_cache_path(api_key: str) -> Path: + digest = hashlib.sha256(api_key.encode("utf-8")).hexdigest()[:16] + return cache_root() / f"opencode-go-limits-{digest}.json" + + +def collect_limits( + candidates: list[Credential], base_url: str, force: bool +) -> dict[str, Any]: + result: dict[str, Any] = { + "limits": [], + "usageStatusText": "", + "authHelpText": AUTH_HELP, + } + + # A panel that is opened and shut repeatedly must not turn into a request + # per flick, so recent probe results are reused for a short window — and + # kept as the answer of record when a later probe fails, for as long as + # their windows have not since reset. A forced refresh bypasses the reuse + # window, but a failed probe still falls back to the open cached windows. + if not candidates: + result["usageStatusText"] = "Waiting for auth" + return result + + min_interval = 0 if force else PROBE_MIN_INTERVAL_SECONDS + refused: tuple[GoUsageError, list[dict[str, Any]], bool] | None = None + + for candidate in candidates: + probe_cache = None + cached: dict[str, Any] = {} + try: + probe_cache = limits_cache_path(candidate.token) + loaded = read_fresh_json(probe_cache, float("inf")) + if isinstance(loaded, dict): + cached = loaded + except Exception: + probe_cache = None + fallback = usable_cached_limits(cached) + fetched_at = cached_fetched_at(cached) + if ( + fallback + and fetched_at is not None + and min_interval > 0 + and time.time() - fetched_at < min_interval + ): + result["limits"] = fallback + return result + + try: + payload = GoUsageClient( + candidate.token, base_url, candidate.path, candidate.headers + ).probe() + limits = parse_usage_payload(payload) + if not limits: + raise GoUsageError( + "OpenCode's usage endpoint returned no usage windows", retry=True + ) + except GoUsageError as error: + # A credential the endpoint refuses may just be the wrong one for this + # account, so the next way in still gets its turn. Anything else is not + # about which credential we picked: a transport failure reached no + # server at all, and any real answer — including an error status — is a + # server we should stop pestering. + if error.auth or error.entitlement: + # Keep the first refusal that still has open windows, so a later + # credential without a cache cannot blank them. + if refused is None or (not refused[1] and fallback): + refused = (error, fallback, candidate.expired) + continue + if error.transport: + result["retryAdvised"] = True + if fallback: + result["limits"] = fallback + else: + result["usageStatusText"] = "OpenCode Go limits unavailable" + result["authHelpText"] = str(error) + return result + + result["limits"] = limits + if probe_cache is not None: + try: + write_json(probe_cache, {"fetchedAtMs": round(time.time() * 1000), "limits": limits}) + except Exception: + pass + return result + + # Every way in was refused. A rejected credential is an account problem, not + # a transient outage: keep any still-open numbers, but say what went wrong. + error, fallback, expired = refused + result["limits"] = fallback + if error.entitlement: + result["usageStatusText"] = "OpenCode Go subscription required" + result["authHelpText"] = ( + "This API key does not include OpenCode Go" + + (" — showing the last known limits." if fallback else ".") + + " " + AUTH_HELP + ) + elif expired: + result["usageStatusText"] = "OpenCode Console session expired" + result["authHelpText"] = CONSOLE_EXPIRED_HELP + else: + result["usageStatusText"] = "Sign-in rejected" + result["authHelpText"] = ( + "OpenCode rejected the stored sign-in" + + (" — showing the last known limits." if fallback else ".") + + " " + AUTH_HELP + ) + return result + + +# -------------------------------------------------------------------- record + + +def scan( + auth_path: Path, + db: Path | None = None, + base_url: str = API_BASE_URL, + force: bool = False, + limits_only: bool = False, +) -> dict[str, Any]: + db = opencode_db_path() if db is None else db + scan_age = 0 if force else (900 if limits_only else 20) + stats = cached_scan(db, scan_age) if db and db.is_file() else empty_stats() + + limits = collect_limits(credential_candidates(auth_path, db), base_url, force) + + record = { + "schemaVersion": 1, + "id": AGENT_ID, + "name": AGENT_NAME, + "updatedAt": dt.datetime.now(dt.timezone.utc).isoformat(), + "ready": number(stats.get("totalPrompts")) > 0 or len(limits["limits"]) > 0, + "hasLocalStats": True, + # The database is machine-local, so cross-device sync sums the numbers + # the way it does for the claude and codex collectors. + "scope": "device", + "hasPromptStats": True, + "tierLabel": "Go", + "usageStatusText": limits["usageStatusText"], + "authHelpText": limits["authHelpText"], + "limits": limits["limits"], + } + if limits.get("retryAdvised"): + record["retryAdvised"] = True + record.update(stats) + return record + + +def main() -> int: + parser = argparse.ArgumentParser(description="Print the OpenCode Go usage record as JSON") + parser.add_argument( + "--force", + action="store_true", + help="rescan the database and re-probe limits, ignoring caches", + ) + parser.add_argument( + "--limits-only", + action="store_true", + help="reuse any recent database scan; only the limits probe must be fresh", + ) + args = parser.parse_args() + + record = scan( + data_home() / "opencode" / "auth.json", + force=args.force, + limits_only=args.limits_only, + ) + print(json.dumps(record, separators=(",", ":"), sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/manual/17-ai.md b/manual/17-ai.md index 0d62622d947..5c6436e67ae 100644 --- a/manual/17-ai.md +++ b/manual/17-ai.md @@ -36,7 +36,7 @@ There are terminal shortcuts too: `a` runs the default agent inline in the curre ### The agents panel -The top bar grows an agents icon the first time Omarchy finds AI coding usage on the machine (and stays out of the way until then). The panel behind it tracks every subscription in one place: your plan, the percentage used of the 5-hour session and weekly limits (or the remaining prepaid balance), and token usage by day and by model. Claude Code, Codex, and Fireworks are covered out of the box. +The top bar grows an agents icon the first time Omarchy finds AI coding usage on the machine (and stays out of the way until then). The panel behind it tracks every subscription in one place: your plan, the percentage used of the 5-hour session and weekly limits (or the remaining prepaid balance), and token usage by day and by model. Claude Code, Codex, OpenCode Go, and Fireworks are covered out of the box. Left-click the bar icon for the panel, right-click to launch your default agent. The usage records behind it are regenerated every 15 minutes by `omarchy agent usage-update`, and the panel can even merge usage from your other machines via a synced folder. See the README under `$OMARCHY_PATH/shell/plugins/agents/` for the full settings. diff --git a/shell/plugins/agents/README.md b/shell/plugins/agents/README.md index 51ad4e43c2d..381b81668d7 100644 --- a/shell/plugins/agents/README.md +++ b/shell/plugins/agents/README.md @@ -55,6 +55,7 @@ light surfaces — and the bar glyph stands in when there is none. | `claude` | Anthropic's OAuth usage endpoint (5-hour session + 7-day weekly) | `~/.claude/projects` transcripts, opencode sessions on an Anthropic provider, plus `stats-cache.json` and `history.jsonl` as fallback | | `codex` | The Codex app-server RPC | native Codex CLI session files (plus pi and opencode sessions) | | `fireworks` | Estimated prepaid balance: configured funding minus rated account costs | Fireworks billing API, grouped by day and model for the last 30 days | +| `opencode-go` | OpenCode Go limits (5-hour, weekly, monthly) | OpenCode SQLite v1/v2 | Claude limits need a signed-in CLI; without credentials the panel says so and falls back to local stats only. A non-default Claude directory is honored via @@ -62,7 +63,42 @@ falls back to local stats only. A non-default Claude directory is honored via `FIREWORKS_API_KEY` and `FIREWORKS_ACCOUNT_ID` first, then `~/.fireworks/auth.ini` (which `firectl set-api-key` creates), then the key opencode stores in `~/.local/share/opencode/auth.json` when Fireworks is -signed in there. +signed in there. OpenCode Go tries each way in: `OPENCODE_API_KEY`, the active +`opencode-go` key in the selected SQLite database, the `opencode-go` entry of +that same `auth.json`, and finally the OpenCode Console session opencode v2 +stores beside them — the credential its own `opencode-go` provider resolves +through. The first one the usage endpoint accepts answers the panel. + +### OpenCode Go limits + +An API key asks `GET https://opencode.ai/zen/go/v1/usage`; a Console session +asks `GET https://opencode.ai/inference/go/v1/usage` carrying the workspace's +`x-opencode-org-id` header, and only when that workspace id is known — a wrong +one is refused outright. Either way the answer is the same rolling / weekly / +monthly percentages the opencode.ai dashboard shows, so the meters are +authoritative account-wide — even for usage burned on other machines or in +other harnesses. Percentages from 0 to 100 are normalized to the panel's +0 to 1 scale, including 0%. +The collector accepts both the deployed `usage..percent/resetsAt` +shape and the historical `Usage.usagePercent/resetInSec` shape. +Local token stats select `OPENCODE_DB`, then `opencode.db`, and +`opencode-v2.db` only when `opencode.db` is absent. Within the selected +database they read both SQLite generations (`message` and `session_message`), +count V2 compaction usage, drop the history a fork copied from its source +session, and deduplicate migrated rows by message ID. + +The limits endpoint is rate-limited, so successful probes are reused for 15 +seconds. The limits cache is keyed by a hash of the selected credential; the +local stats cache follows the selected database path. Neither is reused +without a credential or for another account. A credential the endpoint +refuses gets one more way in tried before the panel is told anything, so a +stale key cannot hide a live Console session. A failed probe keeps open +cached windows, while transport failures ask the panel to retry; a rejected +sign-in, an expired Console session, and a missing Go +subscription each get their own message, because each has a different fix. +Cache entries are dropped when corrupt, future-dated, or past their windows. +`--force` bypasses the stats cache and the 15-second reuse window, but a failed +probe still shows open cached windows. ### Fireworks balance @@ -128,7 +164,8 @@ edit `shell.json` directly): omarchy bar set omarchy.agents providers '{ "claude": { "enabled": true }, "codex": { "enabled": false }, - "fireworks": { "enabled": true } + "fireworks": { "enabled": true }, + "opencode-go": { "enabled": true } }' --json ``` diff --git a/shell/plugins/agents/assets/opencode-go-light.svg b/shell/plugins/agents/assets/opencode-go-light.svg new file mode 100644 index 00000000000..84295dcd5c3 --- /dev/null +++ b/shell/plugins/agents/assets/opencode-go-light.svg @@ -0,0 +1 @@ +OpenCode Go diff --git a/shell/plugins/agents/assets/opencode-go.svg b/shell/plugins/agents/assets/opencode-go.svg new file mode 100644 index 00000000000..6c209faddfe --- /dev/null +++ b/shell/plugins/agents/assets/opencode-go.svg @@ -0,0 +1 @@ +OpenCode Go diff --git a/shell/plugins/agents/manifest.json b/shell/plugins/agents/manifest.json index 1743a798214..2d49abe50b8 100644 --- a/shell/plugins/agents/manifest.json +++ b/shell/plugins/agents/manifest.json @@ -21,7 +21,8 @@ "providers": { "claude": { "enabled": true }, "codex": { "enabled": true }, - "fireworks": { "enabled": true } + "fireworks": { "enabled": true }, + "opencode-go": { "enabled": true } }, "refreshIntervalSec": 900, "syncMode": "Off", diff --git a/test/shell.d/agent-usage-opencode-go-scanner-test.sh b/test/shell.d/agent-usage-opencode-go-scanner-test.sh new file mode 100755 index 00000000000..db9b2641505 --- /dev/null +++ b/test/shell.d/agent-usage-opencode-go-scanner-test.sh @@ -0,0 +1,791 @@ +#!/bin/bash + +source "$(dirname "$0")/base-test.sh" + +require_command jq +require_command python3 + +TEST_HOME=$(mktemp -d) +trap 'rm -rf "$TEST_HOME"' EXIT + +no_key=$(HOME="$TEST_HOME" XDG_DATA_HOME="$TEST_HOME/.local/share" \ + XDG_CACHE_HOME="$TEST_HOME/.cache" OPENCODE_API_KEY="" OPENCODE_DB="" \ + "$ROOT/bin/omarchy-agent-usage-opencode-go") + +[[ $(jq -r '[.id, .ready, .name, .tierLabel] | join(":")' <<<"$no_key") == \ + "opencode-go:false:OpenCode:Go" ]] || + fail "OpenCode collector prints a valid offline record" "$no_key" +pass "OpenCode collector prints a valid offline record" + +result=$(python3 - "$ROOT/bin/omarchy-agent-usage-opencode-go" "$TEST_HOME" <<'PY' +import datetime as dt +import importlib.machinery +import importlib.util +import io +import json +import os +import sqlite3 +import sys +import time +from pathlib import Path + +collector_path = Path(sys.argv[1]) +test_home = Path(sys.argv[2]) +os.environ["TZ"] = "UTC" +time.tzset() +os.environ.pop("OPENCODE_API_KEY", None) +os.environ.pop("OPENCODE_DB", None) + +os.environ["XDG_CACHE_HOME"] = str(test_home / "cache" / "default") +loader = importlib.machinery.SourceFileLoader("collector", str(collector_path)) +spec = importlib.util.spec_from_loader(loader.name, loader) +scanner = importlib.util.module_from_spec(spec) +loader.exec_module(scanner) +real_client = scanner.GoUsageClient + + +def first_token(auth_path, db=None): + found = scanner.credential_candidates(auth_path, db) + return found[0].token if found else "" + + +def probe_limits(key, force=False): + candidates = [scanner.Credential(key, scanner.USAGE_PATH, {})] if key else [] + return scanner.collect_limits(candidates, "https://example.invalid", force) + + +def probe_candidates(keys, force=False): + candidates = [scanner.Credential(key, scanner.USAGE_PATH, {}) for key in keys] + return scanner.collect_limits(candidates, "https://example.invalid", force) + + +data_home = test_home / "data" +os.environ["XDG_DATA_HOME"] = str(data_home) +auth_path = data_home / "opencode" / "auth.json" +auth_path.parent.mkdir(parents=True, exist_ok=True) +auth_path.write_text(json.dumps({"opencode-go": {"type": "api", "key": "sk_auth"}})) +now_ms = int(time.time() * 1000) + + +def day_offset(days): + return now_ms - days * 86400 * 1000 + + +def v1_message(message_id, session_id, provider, model, role="assistant", **tokens): + data = { + "role": role, + "providerID": provider, + "modelID": model, + "tokens": { + "input": tokens.get("input", 0), + "output": tokens.get("output", 0), + "reasoning": tokens.get("reasoning", 0), + "cache": { + "read": tokens.get("read", 0), + "write": tokens.get("write", 0), + }, + }, + "time": {"created": tokens.get("created", now_ms)}, + } + return message_id, session_id, json.dumps(data) + + +def v2_message(message_id, session_id, provider, model, message_type="assistant", **tokens): + data = { + "time": {"created": tokens.get("created", now_ms)}, + "model": {"id": model, "providerID": provider}, + "tokens": { + "input": tokens.get("input", 0), + "output": tokens.get("output", 0), + "reasoning": tokens.get("reasoning", 0), + "cache": { + "read": tokens.get("read", 0), + "write": tokens.get("write", 0), + }, + }, + } + return ( + message_id, session_id, message_type, 1, now_ms, now_ms, json.dumps(data) + ) + + +def create_v1_table(conn): + conn.execute( + "CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT NOT NULL, " + "data TEXT NOT NULL)" + ) + + +def create_v2_table(conn): + conn.execute( + "CREATE TABLE session_message (id TEXT PRIMARY KEY, session_id TEXT NOT NULL, " + "type TEXT NOT NULL, seq INTEGER NOT NULL, time_created INTEGER NOT NULL, " + "time_updated INTEGER NOT NULL, data TEXT NOT NULL)" + ) + conn.execute( + "CREATE TABLE session_v2 (id TEXT PRIMARY KEY, fork_session_id TEXT, " + "time_created INTEGER NOT NULL)" + ) + + +db = data_home / "opencode" / "opencode.db" +conn = sqlite3.connect(db) +create_v1_table(conn) +create_v2_table(conn) +v1_rows = [ + v1_message( + "m1", "s1", "opencode-go", "deepseek-v4-flash", + input=900, output=500, reasoning=100, read=200, write=50, + ), + v1_message( + "m2", "s2", "opencode-go", "deepseek-v4-flash", + input=100, output=20, created=day_offset(2), + ), + v1_message( + "m3", "s3", "opencode-go", "deepseek-v4-flash", + input=50, output=10, created=day_offset(8), + ), + v1_message( + "user", "s4", "opencode-go", "deepseek-v4-flash", + role="user", input=9999, output=9999, + ), + v1_message( + "other", "s5", "anthropic", "claude-opus", input=9999, output=9999, + ), + v1_message("zero", "s6", "opencode-go", "deepseek-v4-flash"), +] +conn.executemany( + "INSERT INTO message (id, session_id, data) VALUES (?, ?, ?)", v1_rows +) +v2_rows = [ + # This duplicate is deliberately different: V2 must win without double-counting. + v2_message( + "m1", "s1", "opencode-go", "deepseek-v4-flash", + input=1000, output=500, reasoning=100, read=200, write=50, + ), + v2_message( + "v2-1", "s-v2", "opencode-go", "deepseek-v4-pro", + input=7, output=3, reasoning=2, read=1, write=4, + ), + v2_message( + "wrong", "s-wrong", "opencode-proxy", "deepseek-v4-pro", + input=999, output=999, + ), + v2_message( + "v2-user", "s-user", "opencode-go", "deepseek-v4-pro", + message_type="user", input=999, output=999, + ), + v2_message("v2-zero", "s-zero", "opencode-go", "deepseek-v4-pro"), + ("v2-bad", "s-bad", "assistant", 1, now_ms, now_ms, "not json"), +] +conn.executemany( + "INSERT INTO session_message " + "(id, session_id, type, seq, time_created, time_updated, data) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + v2_rows, +) + + +# A fork copies its source session's messages under new IDs; OpenCode counts +# only messages created at or after the fork. A compaction row carries usage of +# its own. Both would otherwise be missed or double-counted. +def v2_row_at(message_id, session_id, message_type, created, **tokens): + data = { + "time": {"created": created}, + "model": {"id": "deepseek-v4-pro", "providerID": "opencode-go"}, + "tokens": { + "input": tokens.get("input", 0), + "output": tokens.get("output", 0), + "reasoning": tokens.get("reasoning", 0), + "cache": {"read": 0, "write": 0}, + }, + } + return ( + message_id, session_id, message_type, 1, created, created, json.dumps(data) + ) + + +fork_cutoff = now_ms - 1000 +conn.execute( + "INSERT INTO session_v2 (id, fork_session_id, time_created) VALUES (?, ?, ?)", + ("s-fork", "s-src", fork_cutoff), +) +conn.executemany( + "INSERT INTO session_message " + "(id, session_id, type, seq, time_created, time_updated, data) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + [ + v2_row_at("fork-copied", "s-fork", "assistant", now_ms - 5000, input=5000), + v2_row_at("fork-fresh", "s-fork", "assistant", now_ms, input=30, output=10), + v2_row_at("comp-1", "s-comp", "compaction", now_ms, input=100, output=20), + ], +) +conn.commit() +conn.close() + +stats, complete = scanner.scan_opencode_db(db) + +v1_db = data_home / "v1-only.db" +conn = sqlite3.connect(v1_db) +create_v1_table(conn) +conn.execute( + "INSERT INTO message (id, session_id, data) VALUES (?, ?, ?)", v1_rows[0] +) +conn.commit() +conn.close() +v1_stats, v1_complete = scanner.scan_opencode_db(v1_db) + +v2_db = data_home / "v2-only.db" +conn = sqlite3.connect(v2_db) +create_v2_table(conn) +conn.execute( + "INSERT INTO session_message " + "(id, session_id, type, seq, time_created, time_updated, data) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + v2_message("v2-only", "s-v2-only", "opencode-go", "deepseek-v4-pro", input=11, reasoning=1), +) +conn.commit() +conn.close() +v2_stats, v2_complete = scanner.scan_opencode_db(v2_db) + +selected_db = data_home / "opencode" / "opencode-v2.db" +conn = sqlite3.connect(selected_db) +create_v2_table(conn) +conn.execute( + "CREATE TABLE credential (id TEXT PRIMARY KEY, integration_id TEXT, " + "label TEXT NOT NULL, value TEXT NOT NULL, active INTEGER, " + "time_updated INTEGER NOT NULL)" +) +conn.execute( + "INSERT INTO session_message " + "(id, session_id, type, seq, time_created, time_updated, data) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + v2_message( + "selected", "s-selected", "opencode-go", "muse-spark-1.3-contributor", + input=13, output=2, reasoning=1, + ), +) +conn.execute( + "INSERT INTO credential VALUES (?, ?, ?, ?, ?, ?)", + ( + "active", "opencode-go", "default", + json.dumps({"type": "key", "key": "sk_v2"}), 1, now_ms, + ), +) +conn.commit() +conn.close() + +tui_db = data_home / "opencode" / "opencode-tui-v2.db" +sqlite3.connect(tui_db).close() +selected = scanner.opencode_db_path() +held_db = db.with_name("opencode.db.held") +db.rename(held_db) +legacy = scanner.opencode_db_path() +held_db.rename(db) +os.environ["OPENCODE_DB"] = str(v1_db) +override = scanner.opencode_db_path() +os.environ.pop("OPENCODE_DB") +v2_key = first_token(auth_path, selected_db) +empty_auth = test_home / "empty-auth.json" +empty_auth.write_text("{}") +conn = sqlite3.connect(selected_db) +conn.execute("UPDATE credential SET active = 0") +conn.commit() +conn.close() +inactive_key = first_token(empty_auth, selected_db) +selected_db.unlink() +fallback = scanner.opencode_db_path() + +live_payload = { + "usage": { + "rolling": {"status": "ok", "percent": 2, "resetsAt": "2026-08-13T00:15:17.598Z"}, + "weekly": {"status": "ok", "percent": 3, "resetsAt": "2026-08-17T00:00:00.598Z"}, + "monthly": {"status": "ok", "percent": 38, "resetsAt": "2026-08-27T01:24:06.598Z"}, + } +} +pr_payload = { + "rollingUsage": {"status": "ok", "usagePercent": 19, "resetInSec": 7200}, + "weeklyUsage": {"status": "ok", "usagePercent": 5, "resetInSec": 3600}, +} +limits = scanner.parse_usage_payload(live_payload) +old_limits = scanner.parse_usage_payload(pr_payload) +rate_limited = scanner.parse_usage_payload( + {"usage": {"rolling": {"status": "rate-limited", "percent": 88}}} +) +percentages = [ + scanner.normalize_percent(value) + for value in (0, 0.5, 1, 100, -1, "x") +] +zero_limits = scanner.parse_usage_payload( + {"usage": {"rolling": {"status": "ok", "percent": 0}}} +) + +os.environ["OPENCODE_API_KEY"] = "sk_env" +env_key = first_token(auth_path) == "sk_env" +os.environ.pop("OPENCODE_API_KEY") +auth_key = first_token(auth_path) == "sk_auth" +missing_key = first_token(empty_auth) == "" + + +class FakeClient: + mode = "live" + calls = 0 + reject = set() + last_path = "" + last_headers = {} + + def __init__(self, token, base_url, path, headers=None): + self.token = token + FakeClient.last_path = path + FakeClient.last_headers = headers or {} + + def probe(self): + FakeClient.calls += 1 + if self.token in FakeClient.reject: + raise scanner.GoUsageError("rejected", auth=True) + if FakeClient.mode == "live": + return live_payload + if FakeClient.mode == "reject": + raise scanner.GoUsageError("rejected", auth=True) + if FakeClient.mode == "entitlement": + raise scanner.GoUsageError("subscription required", entitlement=True) + if FakeClient.mode == "offline": + raise scanner.GoUsageError("offline", transport=True) + if FakeClient.mode == "server-error": + raise scanner.GoUsageError( + "OpenCode's usage endpoint returned status 500", retry=True + ) + return {} + + +scanner.GoUsageClient = FakeClient +cache_root = test_home / "cache" / "records" +os.environ["XDG_CACHE_HOME"] = str(cache_root) +record = scanner.scan(auth_path, db, "https://example.invalid") +FakeClient.mode = "reject" +os.environ["XDG_CACHE_HOME"] = str(test_home / "cache" / "reject") +rejected = scanner.scan(auth_path, db, "https://example.invalid") +FakeClient.mode = "offline" +os.environ["XDG_CACHE_HOME"] = str(test_home / "cache" / "offline") +offline = scanner.scan(auth_path, db, "https://example.invalid") + + +def seed_limits(name, entries, age=3600, fetched_at_ms=None, key="sk_auth"): + os.environ["XDG_CACHE_HOME"] = str(test_home / "cache" / name) + cache_file = scanner.limits_cache_path(key) + cache_file.parent.mkdir(parents=True, exist_ok=True) + if fetched_at_ms is None: + fetched_at_ms = int((time.time() - age) * 1000) + cache_file.write_text(json.dumps({"fetchedAtMs": fetched_at_ms, "limits": entries})) + + +open_window = { + "label": "Weekly (7-day)", + "percent": 0.4, + "resetsAt": (dt.datetime.now(dt.timezone.utc) + dt.timedelta(hours=6)).isoformat(), +} +closed_window = { + "label": "Session (5-hour)", + "percent": 0.9, + "resetsAt": (dt.datetime.now(dt.timezone.utc) - dt.timedelta(hours=1)).isoformat(), +} +seed_limits("stale", [closed_window, open_window]) +stale = probe_limits("sk_auth") +seed_limits("reuse", [open_window], age=0) +FakeClient.mode = "offline" +before_reuse = FakeClient.calls +reused = probe_limits("sk_auth") +reuse_calls = FakeClient.calls - before_reuse +FakeClient.mode = "live" +before_force = FakeClient.calls +forced = probe_limits("sk_auth", True) +forced_calls = FakeClient.calls - before_force +seed_limits("forced-fallback", [open_window], age=0) +FakeClient.mode = "offline" +forced_fallback = probe_limits("sk_auth", True) +FakeClient.mode = "live" +seed_limits("expired", [closed_window], age=0) +expired = probe_limits("sk_auth") +seed_limits("rejected-cache", [closed_window, open_window]) +FakeClient.mode = "reject" +rejected_cached = probe_limits("sk_auth") +seed_limits("refused-first", [open_window], key="sk_first") +refused_first = probe_candidates(["sk_first", "sk_second"]) +seed_limits("entitlement", [open_window]) +FakeClient.mode = "entitlement" +entitlement = probe_limits("sk_auth") +seed_limits("other-account", [open_window]) +FakeClient.mode = "offline" +other_account = probe_limits("sk_other") +seed_limits("server-error", [open_window]) +FakeClient.mode = "server-error" +server_error = probe_limits("sk_auth") +no_key = probe_limits("") + +os.environ["XDG_CACHE_HOME"] = str(test_home / "cache" / "corrupt-limit") +cache_file = scanner.limits_cache_path("sk_auth") +cache_file.parent.mkdir(parents=True, exist_ok=True) +cache_file.write_text("[]") +FakeClient.mode = "live" +corrupt_limits = probe_limits("sk_auth") + + +# opencode v2 signs in to OpenCode Console, and the opencode-go provider +# resolves its credentials through that same integration: a different endpoint, +# workspace scoped, and last in line behind every explicit key. +def console_db(name, expires_delta, org="wrk_1"): + path = data_home / "opencode" / name + conn = sqlite3.connect(path) + create_v2_table(conn) + conn.execute( + "CREATE TABLE credential (id TEXT PRIMARY KEY, integration_id TEXT, " + "label TEXT NOT NULL, value TEXT NOT NULL, active INTEGER, " + "time_updated INTEGER NOT NULL)" + ) + metadata = {"email": "a@example.com"} + if org: + metadata["orgID"] = org + conn.execute( + "INSERT INTO credential VALUES (?, ?, ?, ?, ?, ?)", + ( + "console", "opencode", "Default", + json.dumps({ + "type": "oauth", "methodID": "device", "access": "sess_console", + "refresh": "r", "expires": now_ms + expires_delta, "metadata": metadata, + }), + 1, now_ms, + ), + ) + conn.commit() + conn.close() + return path + + +live_console_db = console_db("console-live.db", 86400000) +stale_console_db = console_db("console-stale.db", -1000) +no_org_db = console_db("console-no-org.db", 86400000, org="") +console_candidates = scanner.credential_candidates(auth_path, live_console_db) +console = console_candidates[-1] +console_parse = { + "count": len(console_candidates), + "token": console.token, + "path": console.path, + "org": console.headers.get(scanner.CONSOLE_ORG_HEADER), + "expired": console.expired, +} + +# A dead key in front must not hide the live Console session behind it. +FakeClient.mode = "live" +FakeClient.reject = {"sk_auth"} +os.environ["XDG_CACHE_HOME"] = str(test_home / "cache" / "console") +fallthrough = scanner.collect_limits( + console_candidates, "https://example.invalid", False +) +console_path = FakeClient.last_path +console_headers = dict(FakeClient.last_headers) +FakeClient.reject = set() + +# An expired session is a refresh problem, not a rejected sign-in. +FakeClient.mode = "reject" +os.environ["XDG_CACHE_HOME"] = str(test_home / "cache" / "console-expired") +expired_session = scanner.collect_limits( + scanner.credential_candidates(empty_auth, stale_console_db), + "https://example.invalid", False, +) +FakeClient.mode = "live" +stale_console = scanner.credential_candidates(empty_auth, stale_console_db)[0] +# A wrong workspace is a hard 403, so the header is dropped rather than guessed. +no_org = scanner.credential_candidates(empty_auth, no_org_db)[0] + + +captured = {} + + +class FakeResponse: + def __init__(self, body): + self.body = body + + def read(self): + return self.body + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + +def fake_urlopen(request, timeout=None): + captured["url"] = request.full_url + captured["headers"] = { + name.lower(): value for name, value in request.header_items() + } + if fake_urlopen.error: + raise fake_urlopen.error + return FakeResponse(fake_urlopen.body) + + +fake_urlopen.error = None +fake_urlopen.body = json.dumps(live_payload).encode() +scanner.urllib.request.urlopen = fake_urlopen +probe_client = real_client("sk_probe", "https://example.invalid") +probe_client.probe() + + +def probe_error(error): + fake_urlopen.error = error + try: + probe_client.probe() + except scanner.GoUsageError as caught: + return type(caught).__name__ + ":" + str(caught) + return "no-error" + + +errors = { + "401": probe_error( + scanner.urllib.error.HTTPError( + "https://example.invalid", 401, "Unauthorized", {}, None + ) + ), + "403": probe_error( + scanner.urllib.error.HTTPError( + "https://example.invalid", 403, "Forbidden", {}, + io.BytesIO(b'{"error":{"type":"EntitlementError"}}'), + ) + ), + "500": probe_error( + scanner.urllib.error.HTTPError( + "https://example.invalid", 500, "Server Error", {}, None + ) + ), + "offline": probe_error(scanner.urllib.error.URLError(OSError("offline"))), +} + + +def cache_dir(name): + return test_home / "cache" / "stats" / name + + +def cached(name, age): + os.environ["XDG_CACHE_HOME"] = str(cache_dir(name)) + return scanner.cached_scan(db, age) + + +stats_cache = cached("envelope", 0) +cache_file = next((cache_dir("envelope") / "omarchy" / "agent-usage").glob("*.json")) +envelope = json.loads(cache_file.read_text()) +cache_file.write_text("[]") +cache_recovered = cached("envelope", 900) + +cached("modes", 0) +conn = sqlite3.connect(db) +conn.execute( + "INSERT INTO message (id, session_id, data) VALUES (?, ?, ?)", + v1_message("extra", "s-extra", "opencode-go", "deepseek-v4-flash", input=10), +) +conn.commit() +conn.close() +limits_only = cached("modes", 900) +forced_stats = cached("modes", 0) + +bad_db = test_home / "broken" / "opencode.db" +bad_db.parent.mkdir(parents=True, exist_ok=True) +sqlite3.connect(bad_db).close() +os.environ["XDG_CACHE_HOME"] = str(cache_dir("broken")) +broken_stats, broken_complete = scanner.scan_opencode_db(bad_db) +scanner.cached_scan(bad_db, 20) +broken_cache = list((cache_dir("broken") / "omarchy" / "agent-usage").glob("*.json")) +conn = sqlite3.connect(bad_db) +create_v1_table(conn) +conn.execute( + "INSERT INTO message (id, session_id, data) VALUES (?, ?, ?)", + v1_message("recovered", "s", "opencode-go", "deepseek-v4-flash", input=9), +) +conn.commit() +conn.close() +rescanned = scanner.cached_scan(bad_db, 900) + +unwritable = test_home / "not-a-directory" +unwritable.write_text("blocked") +os.environ["XDG_CACHE_HOME"] = str(unwritable) +unwritable_stats = scanner.cached_scan(db, 20) + +print(json.dumps({ + "stats": stats, + "complete": complete, + "v1": [v1_stats["totalPrompts"], v1_stats["todayTotalTokens"], v1_complete], + "v2": [v2_stats["totalPrompts"], v2_stats["todayTotalTokens"], v2_complete], + "selection": { + "normal": selected.resolve() == db.resolve(), + "legacy": legacy.resolve() == selected_db.resolve(), + "override": override == v1_db.resolve(), + "fallback": fallback.resolve() == db.resolve(), + "ignoresTui": selected != tui_db, + "v2Key": v2_key == "sk_v2", + "inactiveIgnored": inactive_key == "", + }, + "limits": { + "live": limits, + "old": old_limits, + "rate": rate_limited, + "percentages": percentages, + "zero": zero_limits, + }, + "auth": [env_key, auth_key, missing_key], + "record": { + "schemaVersion": record["schemaVersion"], + "id": record["id"], + "name": record["name"], + "ready": record["ready"], + "limits": record["limits"], + }, + "rejected": [rejected["usageStatusText"], rejected["totalPrompts"]], + "offline": [offline.get("retryAdvised"), offline["totalPrompts"]], + "cacheLimits": { + "stale": [w["label"] for w in stale["limits"]], + "reused": [w["label"] for w in reused["limits"]], + "reuseCalls": reuse_calls, + "forcedCalls": forced_calls, + "forcedFallback": [w["label"] for w in forced_fallback["limits"]], + "expired": [w["label"] for w in expired["limits"]], + "rejected": [w["label"] for w in rejected_cached["limits"]], + "rejectedStatus": rejected_cached["usageStatusText"], + "refusedFirst": [w["label"] for w in refused_first["limits"]], + "entitlement": entitlement["usageStatusText"], + "other": [other_account["limits"], other_account["usageStatusText"]], + "serverError": [w["label"] for w in server_error["limits"]], + "serverErrorRetry": server_error.get("retryAdvised"), + "noKey": [no_key["limits"], no_key["usageStatusText"]], + "corrupt": len(corrupt_limits["limits"]), + }, + "probe": { + "url": captured["url"], + "auth": captured["headers"]["authorization"], + "ua": captured["headers"]["user-agent"], + "errors": errors, + }, + "console": { + "parse": console_parse, + "path": console_path, + "orgHeader": console_headers.get(scanner.CONSOLE_ORG_HEADER), + "fallsThrough": [len(fallthrough["limits"]), fallthrough["usageStatusText"]], + "stale": stale_console.expired, + "expiredStatus": expired_session["usageStatusText"], + "droppedOrg": no_org.headers, + }, + "statsCache": { + "date": envelope["scanDate"] == scanner.local_date_string(), + "schema": envelope["schemaVersion"], + "tokens": stats_cache["todayTotalTokens"], + "recovered": cache_recovered["todayTotalTokens"], + "limitsOnly": limits_only["totalPrompts"], + "forced": forced_stats["totalPrompts"], + "broken": [broken_stats["todayTotalTokens"], broken_complete, bool(broken_cache)], + "rescanned": rescanned["todayTotalTokens"], + "unwritable": unwritable_stats["totalPrompts"], + }, +}, separators=(",", ":"))) +PY +) + +[[ $(jq -r '[.stats.todayTotalTokens, .stats.totalPrompts, .complete] | join(":")' \ + <<<"$result") == "2027:6:true" ]] || + fail "OpenCode collector scans V1 and V2 without double-counting" "$result" +pass "OpenCode collector scans V1 and V2 without double-counting" + +[[ $(jq -c '.stats.modelUsage["deepseek-v4-flash"]' <<<"$result") == \ + '{"inputTokens":1150,"outputTokens":630,"cacheReadInputTokens":200,'\ +'"cacheCreationInputTokens":50}' ]] || + fail "OpenCode collector prefers V2 data for duplicate message IDs" "$result" +pass "OpenCode collector prefers V2 data for duplicate message IDs" + +{ [[ $(jq -c '.v1' <<<"$result") == '[1,1750,true]' ]] && + [[ $(jq -c '.v2' <<<"$result") == '[1,12,true]' ]] && + [[ $(jq -r '.stats.activeDays' <<<"$result") == "3" ]]; } || + fail "OpenCode collector supports both SQLite generations and date totals" "$result" +pass "OpenCode collector supports both SQLite generations and date totals" + +[[ $(jq -c '.selection' <<<"$result") == \ + '{"normal":true,"legacy":true,"override":true,"fallback":true,'\ +'"ignoresTui":true,"v2Key":true,"inactiveIgnored":true}' ]] || + fail "OpenCode collector prefers opencode.db, honors overrides, and ignores inactive credentials" "$result" +pass "OpenCode collector prefers opencode.db, honors overrides, and ignores inactive credentials" + +{ jq -e '.limits.live | map(.percent) == [0.02, 0.03, 0.38]' <<<"$result" >/dev/null && + jq -e '.limits.old | map(.percent) == [0.19, 0.05]' <<<"$result" >/dev/null && + jq -e '.limits.percentages == [0, 0.005, 0.01, 1, null, null]' <<<"$result" >/dev/null && + jq -e '[.limits.rate[0].percent, .limits.zero[0].percent] == [1, 0]' \ + <<<"$result" >/dev/null; } || + fail "OpenCode collector parses and normalizes all limit response shapes" "$result" +pass "OpenCode collector parses and normalizes all limit response shapes" + +[[ $(jq -c '.auth' <<<"$result") == '[true,true,true]' ]] || + fail "OpenCode collector applies API key precedence" "$result" +pass "OpenCode collector applies API key precedence" + +{ [[ $(jq -c '.record | {schemaVersion,id,name,ready}' <<<"$result") == \ + '{"schemaVersion":1,"id":"opencode-go","name":"OpenCode","ready":true}' ]] && + [[ $(jq -c '.rejected' <<<"$result") == '["Sign-in rejected",6]' ]] && + [[ $(jq -c '.offline' <<<"$result") == '[true,6]' ]]; } || + fail "OpenCode collector preserves local stats through auth and transport failures" "$result" +pass "OpenCode collector preserves local stats through auth and transport failures" + +{ [[ $(jq -c '.cacheLimits.stale' <<<"$result") == '["Weekly (7-day)"]' ]] && + [[ $(jq -c '.cacheLimits.reused' <<<"$result") == '["Weekly (7-day)"]' ]] && + [[ $(jq -r '.cacheLimits.reuseCalls' <<<"$result") == "0" ]] && + [[ $(jq -r '.cacheLimits.forcedCalls' <<<"$result") == "1" ]] && + [[ $(jq -c '.cacheLimits.forcedFallback' <<<"$result") == '["Weekly (7-day)"]' ]] && + [[ $(jq -c '.cacheLimits.refusedFirst' <<<"$result") == '["Weekly (7-day)"]' ]] && + [[ $(jq -c '.cacheLimits.expired' <<<"$result") == \ + '["Session (5-hour)","Weekly (7-day)","Monthly (30-day)"]' ]] && + [[ $(jq -r '.cacheLimits.rejectedStatus' <<<"$result") == "Sign-in rejected" ]]; } || + fail "OpenCode collector keeps only current, account-specific limit caches" "$result" +pass "OpenCode collector keeps only current, account-specific limit caches" + +{ [[ $(jq -r '.cacheLimits.entitlement' <<<"$result") == \ + "OpenCode Go subscription required" ]] && + [[ $(jq -c '.cacheLimits.other' <<<"$result") == \ + '[[],"OpenCode Go limits unavailable"]' ]] && + [[ $(jq -c '.cacheLimits.noKey' <<<"$result") == '[[],"Waiting for auth"]' ]] && + [[ $(jq -r '.cacheLimits.corrupt' <<<"$result") == "3" ]]; } || + fail "OpenCode collector separates entitlement, account, and corrupt-cache failures" "$result" +pass "OpenCode collector separates entitlement, account, and corrupt-cache failures" + +{ [[ $(jq -c '.cacheLimits.serverError' <<<"$result") == '["Weekly (7-day)"]' ]] && + [[ $(jq -r '.cacheLimits.serverErrorRetry' <<<"$result") == "null" ]]; } || + fail "OpenCode collector keeps cached windows and withholds retry on a 500" "$result" +pass "OpenCode collector keeps cached windows and withholds retry on a 500" + +{ [[ $(jq -r '.probe.url' <<<"$result") == \ + "https://example.invalid/zen/go/v1/usage" ]] && + [[ $(jq -r '.probe.auth + ":" + .probe.ua' <<<"$result") == \ + "Bearer sk_probe:omarchy-agent-usage/1" ]] && + [[ $(jq -r '.probe.errors["401"]' <<<"$result") == *"rejected"* ]] && + [[ $(jq -r '.probe.errors["403"]' <<<"$result") == *"subscription required"* ]] && + [[ $(jq -r '.probe.errors["500"]' <<<"$result") == *"status 500"* ]] && + [[ $(jq -r '.probe.errors.offline' <<<"$result") == *"Could not reach"* ]]; } || + fail "OpenCode probe maps endpoint authentication and transport failures" "$result" +pass "OpenCode probe maps endpoint authentication and transport failures" + +{ [[ $(jq -c '.console.parse' <<<"$result") == \ + '{"count":2,"token":"sess_console","path":"/inference/go/v1/usage",'\ +'"org":"wrk_1","expired":false}' ]] && + [[ $(jq -r '.console.path' <<<"$result") == "/inference/go/v1/usage" ]] && + [[ $(jq -r '.console.orgHeader' <<<"$result") == "wrk_1" ]] && + [[ $(jq -c '.console.fallsThrough' <<<"$result") == '[3,""]' ]] && + [[ $(jq -r '.console.stale' <<<"$result") == "true" ]] && + [[ $(jq -r '.console.expiredStatus' <<<"$result") == \ + "OpenCode Console session expired" ]] && + [[ $(jq -c '.console.droppedOrg' <<<"$result") == '{}' ]]; } || + fail "OpenCode collector falls through to the Console session on its own endpoint" "$result" +pass "OpenCode collector falls through to the Console session on its own endpoint" + +{ [[ $(jq -r '[.statsCache.date, .statsCache.schema, .statsCache.tokens] | join(":")' \ + <<<"$result") == "true:2:2027" ]] && + [[ $(jq -c '.statsCache.recovered' <<<"$result") == "2027" ]] && + [[ $(jq -c '[.statsCache.limitsOnly, .statsCache.forced]' <<<"$result") == "[6,7]" ]] && + [[ $(jq -c '.statsCache.broken' <<<"$result") == '[0,false,false]' ]] && + [[ $(jq -r '.statsCache.rescanned' <<<"$result") == "9" ]] && + [[ $(jq -r '.statsCache.unwritable' <<<"$result") == "7" ]]; } || + fail "OpenCode collector validates and safely bypasses local stats caches" "$result" +pass "OpenCode collector validates and safely bypasses local stats caches"