diff --git a/README.md b/README.md
index 1fa846b5..b2a230af 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@
- Automatic quota toasts after assistant responses
- Manual `/quota`, `/pricing_refresh`, and `/tokens_*` commands for deeper local reporting with zero context window pollution
-**Quota providers**: Anthropic (Claude), GitHub Copilot, OpenAI (Plus/Pro), Cursor, Qwen Code, Alibaba Coding Plan, MiniMax Coding Plan, Chutes AI, Firmware AI, Google Antigravity, Z.ai coding plan, and NanoGPT.
+**Quota providers**: Anthropic (Claude), GitHub Copilot, OpenAI (Plus/Pro), Cursor, Qwen Code, Alibaba Coding Plan, MiniMax Coding Plan, Chutes AI, Firmware AI, Google Antigravity, Z.ai coding plan, NanoGPT, and OpenCode Go.
**Token reports**: All models and providers in [models.dev](https://models.dev), plus deterministic local pricing for Cursor Auto/Composer and Cursor model aliases that are not on models.dev.
@@ -90,6 +90,7 @@ That is enough for most installs. Providers are auto-detected from your existing
| **Z.ai** | Yes | OpenCode auth (API key) | Remote API |
| **NanoGPT** | Usually | OpenCode auth (API key; env/global config fallback) | Remote API |
| **MiniMax Coding Plan** | Yes | OpenCode auth (API key) | Remote API |
+| **OpenCode Go** | Needs [quick setup](#opencode-go-quick-setup) | State only (env vars or config file) | Remote API (dashboard scraping) |
@@ -178,6 +179,37 @@ For behavior details and troubleshooting, see [Qwen Code notes](#qwen-code-notes
+
+
+Quick setup: OpenCode Go
+
+OpenCode Go quota scrapes the OpenCode Go dashboard. It requires a workspace ID and an auth cookie.
+
+**Option A — Environment variables:**
+
+```sh
+export OPENCODE_GO_WORKSPACE_ID="your-workspace-id"
+export OPENCODE_GO_AUTH_COOKIE="your-auth-cookie"
+```
+
+**Option B — Config file** (`~/.config/opencode/opencode-quota/opencode-go.json`):
+
+```json
+{
+ "workspaceId": "your-workspace-id",
+ "authCookie": "your-auth-cookie"
+}
+```
+
+To find these values:
+
+1. **workspaceId** — Visit [opencode.ai](https://opencode.ai), open your workspace, and copy the workspace ID from the URL: `https://opencode.ai/workspace//go`.
+2. **authCookie** — Open your browser DevTools on `opencode.ai`, go to Application → Cookies, and copy the value of the `auth` cookie.
+
+Environment variables take precedence over the config file. For behavior details and troubleshooting, see [OpenCode Go notes](#opencode-go-notes).
+
+
+
## Commands
| Command | What it shows |
@@ -475,6 +507,31 @@ Example user/global config (`~/.config/opencode/opencode.jsonc` on Linux/macOS):
+
+
+OpenCode Go
+
+OpenCode Go quota scrapes the OpenCode Go dashboard at `https://opencode.ai/workspace//go` using an `auth` cookie. There is no official usage API yet; the plugin parses the SolidJS SSR hydration output for `monthlyUsage` data.
+
+- **Config sources** (checked in order):
+ 1. Environment variables: `OPENCODE_GO_WORKSPACE_ID` and `OPENCODE_GO_AUTH_COOKIE`
+ 2. Config file: `~/.config/opencode/opencode-quota/opencode-go.json` with `{ "workspaceId": "...", "authCookie": "..." }`
+- Environment variables take precedence. Both `workspaceId` and `authCookie` must come from the same source.
+- Quota returns a usage percentage and a reset countdown. There is no absolute request count.
+- `/quota_status` shows an `opencode_go` section with config state, config source, checked paths, and live scrape results or errors.
+- Because this is a scraper, it may break if OpenCode changes their dashboard markup. An official API ([opencode#16513](https://github.com/anomalyco/opencode/pull/16513)) is pending.
+
+**Troubleshooting:**
+
+| Problem | Solution |
+| --- | --- |
+| Config not detected | Confirm `OPENCODE_GO_WORKSPACE_ID` and `OPENCODE_GO_AUTH_COOKIE` are set, or the config file exists |
+| Incomplete config | Both `workspaceId` and `authCookie` are required; check `/quota_status` for which field is missing |
+| Scrape returns no data | The auth cookie may have expired; get a fresh one from your browser |
+| Dashboard format changed | The SolidJS SSR pattern may have changed; file an issue or wait for the official API |
+
+
+
## Configuration Reference
All plugin settings live under `experimental.quotaToast`.
diff --git a/src/lib/opencode-go-config.ts b/src/lib/opencode-go-config.ts
new file mode 100644
index 00000000..f0846f74
--- /dev/null
+++ b/src/lib/opencode-go-config.ts
@@ -0,0 +1,131 @@
+import { readFile } from "fs/promises";
+import { join } from "path";
+
+import { getOpencodeRuntimeDirCandidates } from "./opencode-runtime-paths.js";
+
+export interface OpenCodeGoConfig {
+ workspaceId: string;
+ authCookie: string;
+}
+
+export type ResolvedOpenCodeGoConfig =
+ | { state: "none" }
+ | { state: "configured"; config: OpenCodeGoConfig; source: string }
+ | { state: "incomplete"; source: string; missing: string };
+
+export interface OpenCodeGoConfigDiagnostics {
+ state: ResolvedOpenCodeGoConfig["state"];
+ source: string | null;
+ missing: string | null;
+ checkedPaths: string[];
+}
+
+function getConfigCandidatePaths(): string[] {
+ const { configDirs } = getOpencodeRuntimeDirCandidates();
+ return configDirs.map((dir) => join(dir, "opencode-quota", "opencode-go.json"));
+}
+
+async function readConfigFile(path: string): Promise | null> {
+ try {
+ const data = await readFile(path, "utf-8");
+ const parsed = JSON.parse(data) as Record;
+ if (!parsed || typeof parsed !== "object") return null;
+ return parsed as Partial;
+ } catch {
+ return null;
+ }
+}
+
+export function resolveOpenCodeGoConfigFromEnv(
+ env: NodeJS.ProcessEnv = process.env,
+): ResolvedOpenCodeGoConfig | null {
+ const workspaceId = env.OPENCODE_GO_WORKSPACE_ID?.trim();
+ const authCookie = env.OPENCODE_GO_AUTH_COOKIE?.trim();
+
+ if (!workspaceId && !authCookie) return null;
+
+ if (workspaceId && authCookie) {
+ return {
+ state: "configured",
+ config: { workspaceId, authCookie },
+ source: "env",
+ };
+ }
+
+ return {
+ state: "incomplete",
+ source: "env",
+ missing: workspaceId ? "OPENCODE_GO_AUTH_COOKIE" : "OPENCODE_GO_WORKSPACE_ID",
+ };
+}
+
+export async function resolveOpenCodeGoConfig(): Promise {
+ const envResult = resolveOpenCodeGoConfigFromEnv();
+ if (envResult) return envResult;
+
+ const candidates = getConfigCandidatePaths();
+ for (const path of candidates) {
+ const config = await readConfigFile(path);
+ if (!config) continue;
+
+ const workspaceId = typeof config.workspaceId === "string" ? config.workspaceId.trim() : "";
+ const authCookie = typeof config.authCookie === "string" ? config.authCookie.trim() : "";
+
+ if (workspaceId && authCookie) {
+ return {
+ state: "configured",
+ config: { workspaceId, authCookie },
+ source: path,
+ };
+ }
+
+ const missing = !workspaceId ? "workspaceId" : "authCookie";
+ return { state: "incomplete", source: path, missing };
+ }
+
+ return { state: "none" };
+}
+
+let cachedConfig: ResolvedOpenCodeGoConfig | null = null;
+let cachedAt = 0;
+
+const DEFAULT_CACHE_MAX_AGE_MS = 30_000;
+export { DEFAULT_CACHE_MAX_AGE_MS as DEFAULT_OPENCODE_GO_CONFIG_CACHE_MAX_AGE_MS };
+
+export async function resolveOpenCodeGoConfigCached(params?: {
+ maxAgeMs?: number;
+}): Promise {
+ const maxAgeMs = Math.max(0, params?.maxAgeMs ?? DEFAULT_CACHE_MAX_AGE_MS);
+ const now = Date.now();
+ if (cachedConfig && now - cachedAt < maxAgeMs) {
+ return cachedConfig;
+ }
+ cachedConfig = await resolveOpenCodeGoConfig();
+ cachedAt = now;
+ return cachedConfig;
+}
+
+export async function getOpenCodeGoConfigDiagnostics(): Promise {
+ const resolved = await resolveOpenCodeGoConfig();
+ const checkedPaths = getConfigCandidatePaths();
+
+ if (resolved.state === "none") {
+ return { state: "none", source: null, missing: null, checkedPaths };
+ }
+
+ if (resolved.state === "incomplete") {
+ return {
+ state: "incomplete",
+ source: resolved.source,
+ missing: resolved.missing,
+ checkedPaths,
+ };
+ }
+
+ return {
+ state: "configured",
+ source: resolved.source,
+ missing: null,
+ checkedPaths,
+ };
+}
diff --git a/src/lib/opencode-go.ts b/src/lib/opencode-go.ts
new file mode 100644
index 00000000..15a1fecc
--- /dev/null
+++ b/src/lib/opencode-go.ts
@@ -0,0 +1,117 @@
+/**
+ * OpenCode Go dashboard scraper.
+ *
+ * Fetches the OpenCode Go workspace page and parses SolidJS SSR hydration
+ * output for `monthlyUsage` containing `usagePercent` and `resetInSec`.
+ */
+
+import { fetchWithTimeout } from "./http.js";
+import { sanitizeDisplayText } from "./display-sanitize.js";
+import type { OpenCodeGoResult } from "./types.js";
+
+const DASHBOARD_URL_PREFIX = "https://opencode.ai/workspace/";
+const DASHBOARD_URL_SUFFIX = "/go";
+const USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Gecko/20100101 Firefox/148.0";
+
+const SCRAPE_TIMEOUT_MS = 10_000;
+
+/**
+ * Regex patterns matching the SolidJS SSR hydration output.
+ * Field order may vary, so we try both orderings.
+ */
+const RE_MONTHLY_PCT_FIRST =
+ /monthlyUsage:\$R\[\d+\]=\{[^}]*usagePercent:(\d+)[^}]*resetInSec:(\d+)[^}]*\}/;
+const RE_MONTHLY_RESET_FIRST =
+ /monthlyUsage:\$R\[\d+\]=\{[^}]*resetInSec:(\d+)[^}]*usagePercent:(\d+)[^}]*\}/;
+
+interface ScrapedMonthlyUsage {
+ usagePercent: number;
+ resetInSec: number;
+}
+
+function parseMonthlyUsage(html: string): ScrapedMonthlyUsage | null {
+ const pctFirstMatch = RE_MONTHLY_PCT_FIRST.exec(html);
+ if (pctFirstMatch) {
+ const usagePercent = Number(pctFirstMatch[1]);
+ const resetInSec = Number(pctFirstMatch[2]);
+ if (Number.isFinite(usagePercent) && Number.isFinite(resetInSec)) {
+ return { usagePercent, resetInSec };
+ }
+ }
+
+ const resetFirstMatch = RE_MONTHLY_RESET_FIRST.exec(html);
+ if (resetFirstMatch) {
+ const resetInSec = Number(resetFirstMatch[1]);
+ const usagePercent = Number(resetFirstMatch[2]);
+ if (Number.isFinite(usagePercent) && Number.isFinite(resetInSec)) {
+ return { usagePercent, resetInSec };
+ }
+ }
+
+ return null;
+}
+
+function sanitizeMessage(text: string, maxLength = 120): string {
+ const sanitized = sanitizeDisplayText(text).replace(/\s+/g, " ").trim();
+ return (sanitized || "unknown").slice(0, maxLength);
+}
+
+export async function queryOpenCodeGoQuota(
+ workspaceId: string,
+ authCookie: string,
+): Promise {
+ try {
+ const url = `${DASHBOARD_URL_PREFIX}${encodeURIComponent(workspaceId)}${DASHBOARD_URL_SUFFIX}`;
+
+ const response = await fetchWithTimeout(
+ url,
+ {
+ method: "GET",
+ headers: {
+ "User-Agent": USER_AGENT,
+ Accept: "text/html",
+ Cookie: `auth=${authCookie}`,
+ },
+ },
+ SCRAPE_TIMEOUT_MS,
+ );
+
+ if (!response.ok) {
+ const text = await response.text();
+ return {
+ success: false,
+ error: `OpenCode Go dashboard error ${response.status}: ${sanitizeMessage(text)}`,
+ };
+ }
+
+ const html = await response.text();
+ const monthly = parseMonthlyUsage(html);
+
+ if (!monthly) {
+ return {
+ success: false,
+ error: "Could not parse monthly usage from OpenCode Go dashboard",
+ };
+ }
+
+ const usagePercent = Math.max(0, Math.min(100, monthly.usagePercent));
+ const percentRemaining = 100 - usagePercent;
+ const resetInSec = Math.max(0, monthly.resetInSec);
+ const resetTimeIso = new Date(Date.now() + resetInSec * 1000).toISOString();
+
+ return {
+ success: true,
+ usagePercent,
+ resetInSec,
+ percentRemaining,
+ resetTimeIso,
+ };
+ } catch (err) {
+ return {
+ success: false,
+ error: sanitizeMessage(err instanceof Error ? err.message : String(err)),
+ };
+ }
+}
+
+export { parseMonthlyUsage as _parseMonthlyUsage };
diff --git a/src/lib/provider-metadata.ts b/src/lib/provider-metadata.ts
index bfd0f88e..2bf13af1 100644
--- a/src/lib/provider-metadata.ts
+++ b/src/lib/provider-metadata.ts
@@ -10,7 +10,8 @@ export type CanonicalQuotaProviderId =
| "google-antigravity"
| "zai"
| "nanogpt"
- | "minimax-coding-plan";
+ | "minimax-coding-plan"
+ | "opencode-go";
export type QuotaProviderAutoSetup = "yes" | "usually" | "needs_quick_setup";
@@ -22,9 +23,7 @@ export type QuotaProviderAuthentication =
| "github_oauth_or_pat"
| "state_only";
-export type QuotaProviderAuthFallback =
- | "env_api_key"
- | "global_opencode_config";
+export type QuotaProviderAuthFallback = "env_api_key" | "global_opencode_config";
export type QuotaProviderQuotaSource =
| "remote_api"
@@ -41,9 +40,7 @@ export interface QuotaProviderShape {
notes?: string;
}
-export type QuotaProviderRuntimeIds = Readonly<
- Record
->;
+export type QuotaProviderRuntimeIds = Readonly>;
export const QUOTA_PROVIDER_LABELS: Readonly> = {
anthropic: "Anthropic",
@@ -58,6 +55,7 @@ export const QUOTA_PROVIDER_LABELS: Readonly> = {
zai: "Z.ai",
nanogpt: "NanoGPT",
"minimax-coding-plan": "MiniMax Coding Plan",
+ "opencode-go": "OpenCode Go",
};
export const QUOTA_PROVIDER_ID_SYNONYMS: Readonly> = {
@@ -73,6 +71,7 @@ export const QUOTA_PROVIDER_ID_SYNONYMS: Readonly> = {
alibaba: "alibaba-coding-plan",
"nano-gpt": "nanogpt",
minimax: "minimax-coding-plan",
+ "opencode-go-subscription": "opencode-go",
};
export const QUOTA_PROVIDER_RUNTIME_IDS: QuotaProviderRuntimeIds = {
@@ -88,6 +87,7 @@ export const QUOTA_PROVIDER_RUNTIME_IDS: QuotaProviderRuntimeIds = {
zai: ["zai", "glm", "zai-coding-plan"],
nanogpt: ["nanogpt", "nano-gpt"],
"minimax-coding-plan": ["minimax-coding-plan", "minimax"],
+ "opencode-go": ["opencode-go"],
};
export const QUOTA_PROVIDER_SHAPES: readonly QuotaProviderShape[] = [
@@ -168,13 +168,18 @@ export const QUOTA_PROVIDER_SHAPES: readonly QuotaProviderShape[] = [
authentication: "opencode_auth_api_key",
quota: "remote_api",
},
+ {
+ id: "opencode-go",
+ autoSetup: "needs_quick_setup",
+ authentication: "state_only",
+ quota: "remote_api",
+ notes: "Scrapes the OpenCode Go dashboard; requires workspaceId and authCookie",
+ },
];
const QUOTA_PROVIDER_SHAPES_BY_ID: Readonly<
Partial>
-> = Object.fromEntries(
- QUOTA_PROVIDER_SHAPES.map((shape) => [shape.id, shape]),
-);
+> = Object.fromEntries(QUOTA_PROVIDER_SHAPES.map((shape) => [shape.id, shape]));
export function normalizeQuotaProviderId(id: string): string {
const normalized = id.trim().toLowerCase();
diff --git a/src/lib/quota-status.ts b/src/lib/quota-status.ts
index aca79163..c4f2d83e 100644
--- a/src/lib/quota-status.ts
+++ b/src/lib/quota-status.ts
@@ -29,10 +29,7 @@ import {
getMiniMaxAuthDiagnostics,
resolveMiniMaxAuthCached,
} from "./minimax-auth.js";
-import {
- DEFAULT_ZAI_AUTH_CACHE_MAX_AGE_MS,
- getZaiAuthDiagnostics,
-} from "./zai-auth.js";
+import { DEFAULT_ZAI_AUTH_CACHE_MAX_AGE_MS, getZaiAuthDiagnostics } from "./zai-auth.js";
import {
getPricingSnapshotHealth,
getPricingRefreshPolicy,
@@ -58,13 +55,16 @@ import { totalTokenBuckets } from "./token-buckets.js";
import { inspectCursorAuthPresence, inspectCursorOpenCodeIntegration } from "./cursor-detection.js";
import { getCurrentCursorUsageSummary } from "./cursor-usage.js";
import { sanitizeDisplayText } from "./display-sanitize.js";
-import {
- getCursorPlanDisplayName,
- getEffectiveCursorIncludedApiUsd,
-} from "./cursor-pricing.js";
+import { getCursorPlanDisplayName, getEffectiveCursorIncludedApiUsd } from "./cursor-pricing.js";
import type { CursorQuotaPlan, PricingSnapshotSource } from "./types.js";
import { queryMiniMaxQuota } from "../providers/minimax-coding-plan.js";
import { queryZaiQuota } from "./zai.js";
+import {
+ getOpenCodeGoConfigDiagnostics,
+ resolveOpenCodeGoConfigCached,
+ DEFAULT_OPENCODE_GO_CONFIG_CACHE_MAX_AGE_MS,
+} from "./opencode-go-config.js";
+import { queryOpenCodeGoQuota } from "./opencode-go.js";
/** Session token fetch error info for status report */
export interface SessionTokenError {
@@ -158,10 +158,7 @@ async function readNanoGptApiKeyDiagnostics(
}
}
-function appendNanoGptApiKeySection(
- lines: string[],
- diagnostics: NanoGptApiKeyDiagnostics,
-): void {
+function appendNanoGptApiKeySection(lines: string[], diagnostics: NanoGptApiKeyDiagnostics): void {
lines.push("");
lines.push("nanogpt:");
lines.push(`- api_key_configured: ${diagnostics.configured ? "true" : "false"}`);
@@ -263,7 +260,8 @@ function supportedProviderPricingRow(params: {
return {
id,
pricing: "partial",
- notes: "API-pool models map to official pricing; Auto/Composer use bundled static Cursor rates",
+ notes:
+ "API-pool models map to official pricing; Auto/Composer use bundled static Cursor rates",
};
}
@@ -275,6 +273,14 @@ function supportedProviderPricingRow(params: {
};
}
+ if (id === "opencode-go") {
+ return {
+ id,
+ pricing: "no",
+ notes: "subscription percentage quota via dashboard scraping (not token-priced)",
+ };
+ }
+
// Providers that correspond directly to models.dev providers.
if (params.snapshotProviders.includes(id)) {
return { id, pricing: "yes", notes: "models.dev snapshot provider" };
@@ -423,13 +429,20 @@ export async function buildQuotaStatusReport(params: {
maxAgeMs: DEFAULT_ALIBABA_AUTH_CACHE_MAX_AGE_MS,
fallbackTier: params.alibabaCodingPlanTier,
});
- const alibabaCodingPlanAuth = resolveAlibabaCodingPlanAuth(authData, params.alibabaCodingPlanTier);
+ const alibabaCodingPlanAuth = resolveAlibabaCodingPlanAuth(
+ authData,
+ params.alibabaCodingPlanTier,
+ );
lines.push(`- qwen oauth auth configured: ${qwenAuthConfigured ? "true" : "false"}`);
lines.push(
`- qwen_oauth_source: ${qwenLocalPlan.state === "qwen_free" ? qwenLocalPlan.sourceKey : "(none)"}`,
);
- lines.push(`- qwen_local_plan: ${qwenLocalPlan.state === "qwen_free" ? "qwen-code/free" : "(none)"}`);
- lines.push(`- alibaba auth configured: ${alibabaAuthDiagnostics.state === "none" ? "false" : "true"}`);
+ lines.push(
+ `- qwen_local_plan: ${qwenLocalPlan.state === "qwen_free" ? "qwen-code/free" : "(none)"}`,
+ );
+ lines.push(
+ `- alibaba auth configured: ${alibabaAuthDiagnostics.state === "none" ? "false" : "true"}`,
+ );
lines.push(`- alibaba coding plan fallback tier: ${params.alibabaCodingPlanTier}`);
lines.push(
`- alibaba_coding_plan: ${alibabaAuthDiagnostics.state === "configured" ? alibabaAuthDiagnostics.tier : alibabaAuthDiagnostics.state === "invalid" ? "invalid" : "(none)"}`,
@@ -527,7 +540,9 @@ export async function buildQuotaStatusReport(params: {
});
lines.push(`- cycle_source: ${cursorUsage.window.source}`);
lines.push(`- cycle_reset_at: ${cursorUsage.window.resetTimeIso}`);
- lines.push(`- api_usage: ${fmtUsdAmount(cursorUsage.api.costUsd)} across ${fmtInt(cursorUsage.api.messageCount)} messages`);
+ lines.push(
+ `- api_usage: ${fmtUsdAmount(cursorUsage.api.costUsd)} across ${fmtInt(cursorUsage.api.messageCount)} messages`,
+ );
lines.push(
`- auto_composer_usage: ${fmtUsdAmount(cursorUsage.autoComposer.costUsd)} across ${fmtInt(cursorUsage.autoComposer.messageCount)} messages`,
);
@@ -623,6 +638,38 @@ export async function buildQuotaStatusReport(params: {
}
}
+ lines.push("");
+ lines.push("opencode_go:");
+ const openCodeGoDiag = await getOpenCodeGoConfigDiagnostics();
+ lines.push(`- config_state: ${openCodeGoDiag.state}`);
+ lines.push(`- config_source: ${openCodeGoDiag.source ?? "(none)"}`);
+ if (openCodeGoDiag.missing) {
+ lines.push(`- config_missing: ${openCodeGoDiag.missing}`);
+ }
+ lines.push(`- config_checked_paths: ${joinOrNone(openCodeGoDiag.checkedPaths)}`);
+ if (openCodeGoDiag.state === "configured") {
+ const openCodeGoConfig = await resolveOpenCodeGoConfigCached({
+ maxAgeMs: DEFAULT_OPENCODE_GO_CONFIG_CACHE_MAX_AGE_MS,
+ });
+ if (openCodeGoConfig.state !== "configured") {
+ lines.push("- live_fetch_error: OpenCode Go config became unavailable before fetch");
+ } else {
+ const openCodeGoQuota = await queryOpenCodeGoQuota(
+ openCodeGoConfig.config.workspaceId,
+ openCodeGoConfig.config.authCookie,
+ );
+ if (!openCodeGoQuota) {
+ lines.push("- live_fetch_error: OpenCode Go returned null");
+ } else if (!openCodeGoQuota.success) {
+ lines.push(`- live_fetch_error: ${openCodeGoQuota.error}`);
+ } else {
+ lines.push(
+ `- monthly_usage: percent_used=${openCodeGoQuota.usagePercent} percent_remaining=${openCodeGoQuota.percentRemaining} reset_in_sec=${openCodeGoQuota.resetInSec} reset_at=${openCodeGoQuota.resetTimeIso}`,
+ );
+ }
+ }
+ }
+
lines.push("");
lines.push("zai:");
const zaiAuth = await getZaiAuthDiagnostics({
@@ -752,21 +799,29 @@ export async function buildQuotaStatusReport(params: {
lines.push(`- billing_mode: ${copilotDiag.billingMode}`);
lines.push(`- billing_scope: ${copilotDiag.billingScope}`);
lines.push(`- quota_api: ${copilotDiag.quotaApi}`);
- lines.push(`- billing_api_access_likely: ${copilotDiag.billingApiAccessLikely ? "true" : "false"}`);
+ lines.push(
+ `- billing_api_access_likely: ${copilotDiag.billingApiAccessLikely ? "true" : "false"}`,
+ );
lines.push(`- remaining_totals_state: ${copilotDiag.remainingTotalsState}`);
if (copilotDiag.queryPeriod) {
- lines.push(`- billing_period: ${copilotDiag.queryPeriod.year}-${String(copilotDiag.queryPeriod.month).padStart(2, "0")}`);
+ lines.push(
+ `- billing_period: ${copilotDiag.queryPeriod.year}-${String(copilotDiag.queryPeriod.month).padStart(2, "0")}`,
+ );
}
if (copilotDiag.usernameFilter) {
lines.push(`- username_filter: ${copilotDiag.usernameFilter}`);
}
if (copilotDiag.billingMode === "organization_usage") {
lines.push("- billing_usage_note: organization premium usage for the current billing period");
- lines.push("- remaining_quota_note: valid PAT access can query billing usage, but pooled org usage does not provide a true per-user remaining quota");
+ lines.push(
+ "- remaining_quota_note: valid PAT access can query billing usage, but pooled org usage does not provide a true per-user remaining quota",
+ );
}
if (copilotDiag.billingMode === "enterprise_usage") {
lines.push("- billing_usage_note: enterprise premium usage for the current billing period");
- lines.push("- remaining_quota_note: valid enterprise billing access can query pooled enterprise usage, but it does not provide a true per-user remaining quota");
+ lines.push(
+ "- remaining_quota_note: valid enterprise billing access can query pooled enterprise usage, but it does not provide a true per-user remaining quota",
+ );
}
if (copilotDiag.billingTargetError) {
lines.push(`- billing_target_error: ${copilotDiag.billingTargetError}`);
@@ -872,9 +927,7 @@ export async function buildQuotaStatusReport(params: {
lines.push(
`- pricing: source=${meta.source} active_source=${snapshotSource} generated_at=${new Date(meta.generatedAt).toISOString()} units=${meta.units}`,
);
- lines.push(
- `- selection: configured=${params.pricingSnapshotSource} active=${snapshotSource}`,
- );
+ lines.push(`- selection: configured=${params.pricingSnapshotSource} active=${snapshotSource}`);
if (params.pricingSnapshotSource === "bundled") {
lines.push(
"- selection_note: bundled config pins the packaged snapshot and ignores runtime refresh for active pricing",
@@ -937,7 +990,7 @@ export async function buildQuotaStatusReport(params: {
const src = `${row.key.sourceProviderID}/${row.key.sourceModelID}`;
const mapped = `${row.key.mappedProvider}/${row.key.mappedModel}`;
lines.push(
- `- ${src} mapped=${mapped} tokens=${fmtInt(totalTokenBuckets(row.tokens))} msgs=${fmtInt(row.messageCount)} reason=${row.key.reason}`,
+ `- ${src} mapped=${mapped} tokens=${fmtInt(totalTokenBuckets(row.tokens))} msgs=${fmtInt(row.messageCount)} reason=${row.key.reason}`,
);
}
if (agg.unpriced.length > STATUS_SAMPLE_LIMIT) {
@@ -966,7 +1019,7 @@ export async function buildQuotaStatusReport(params: {
? ` candidates=${row.key.providerCandidates.join(",")}`
: "";
lines.push(
- `- ${src} mapped=${mappedBase}${candidates} tokens=${fmtInt(totalTokenBuckets(row.tokens))} msgs=${fmtInt(row.messageCount)}`,
+ `- ${src} mapped=${mappedBase}${candidates} tokens=${fmtInt(totalTokenBuckets(row.tokens))} msgs=${fmtInt(row.messageCount)}`,
);
}
if (agg.unknown.length > STATUS_SAMPLE_LIMIT) {
diff --git a/src/lib/types.ts b/src/lib/types.ts
index d1350f52..7efc209b 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -457,10 +457,26 @@ export type MiniMaxResult =
| QuotaError;
export type ChutesResult =
| {
- success: true;
- percentRemaining: number;
- resetTimeIso?: string;
- }
+ success: true;
+ percentRemaining: number;
+ resetTimeIso?: string;
+ }
+ | QuotaError
+ | null;
+
+/** Result from scraping OpenCode Go dashboard usage */
+export type OpenCodeGoResult =
+ | {
+ success: true;
+ /** Usage percentage [0..100] */
+ usagePercent: number;
+ /** Seconds until usage resets */
+ resetInSec: number;
+ /** Remaining percentage [0..100] */
+ percentRemaining: number;
+ /** ISO reset timestamp */
+ resetTimeIso: string;
+ }
| QuotaError
| null;
@@ -482,8 +498,16 @@ export const GOOGLE_MODEL_KEYS: Record<
GoogleModelId,
{ key: string; altKey?: string; display: string }
> = {
- G3PRO: { key: "gemini-3.1-pro", altKey: "gemini-3.1-pro-high|gemini-3.1-pro-low|gemini-3-pro-high|gemini-3-pro-low", display: "G3Pro" },
+ G3PRO: {
+ key: "gemini-3.1-pro",
+ altKey: "gemini-3.1-pro-high|gemini-3.1-pro-low|gemini-3-pro-high|gemini-3-pro-low",
+ display: "G3Pro",
+ },
G3FLASH: { key: "gemini-3-flash", display: "G3Flash" },
- CLAUDE: { key: "claude-opus-4-6-thinking", altKey: "claude-opus-4-5-thinking|claude-opus-4-5", display: "Claude" },
+ CLAUDE: {
+ key: "claude-opus-4-6-thinking",
+ altKey: "claude-opus-4-5-thinking|claude-opus-4-5",
+ display: "Claude",
+ },
G3IMAGE: { key: "gemini-3-pro-image", display: "G3Image" },
};
diff --git a/src/providers/opencode-go.ts b/src/providers/opencode-go.ts
new file mode 100644
index 00000000..f41b9adb
--- /dev/null
+++ b/src/providers/opencode-go.ts
@@ -0,0 +1,83 @@
+/**
+ * OpenCode Go provider wrapper.
+ *
+ * Scrapes the OpenCode Go workspace dashboard and reports monthly usage
+ * as a percentage-based quota entry.
+ */
+
+import type { QuotaProvider, QuotaProviderContext, QuotaProviderResult } from "../lib/entries.js";
+import {
+ DEFAULT_OPENCODE_GO_CONFIG_CACHE_MAX_AGE_MS,
+ resolveOpenCodeGoConfigCached,
+} from "../lib/opencode-go-config.js";
+import { queryOpenCodeGoQuota } from "../lib/opencode-go.js";
+import { normalizeQuotaProviderId } from "../lib/provider-metadata.js";
+
+const OPENCODE_GO_PROVIDER_LABEL = "OpenCode Go";
+
+export const opencodeGoProvider: QuotaProvider = {
+ id: "opencode-go",
+
+ async isAvailable(_ctx: QuotaProviderContext): Promise {
+ const config = await resolveOpenCodeGoConfigCached({
+ maxAgeMs: DEFAULT_OPENCODE_GO_CONFIG_CACHE_MAX_AGE_MS,
+ });
+ return config.state === "configured" || config.state === "incomplete";
+ },
+
+ matchesCurrentModel(model: string): boolean {
+ const [provider] = model.toLowerCase().split("/", 2);
+ return normalizeQuotaProviderId(provider) === "opencode-go";
+ },
+
+ async fetch(_ctx: QuotaProviderContext): Promise {
+ const config = await resolveOpenCodeGoConfigCached({
+ maxAgeMs: DEFAULT_OPENCODE_GO_CONFIG_CACHE_MAX_AGE_MS,
+ });
+
+ if (config.state === "none") {
+ return { attempted: false, entries: [], errors: [] };
+ }
+
+ if (config.state === "incomplete") {
+ return {
+ attempted: true,
+ entries: [],
+ errors: [
+ {
+ label: OPENCODE_GO_PROVIDER_LABEL,
+ message: `Missing ${config.missing} (source: ${config.source})`,
+ },
+ ],
+ };
+ }
+
+ const result = await queryOpenCodeGoQuota(config.config.workspaceId, config.config.authCookie);
+
+ if (!result) {
+ return { attempted: false, entries: [], errors: [] };
+ }
+
+ if (!result.success) {
+ return {
+ attempted: true,
+ entries: [],
+ errors: [{ label: OPENCODE_GO_PROVIDER_LABEL, message: result.error }],
+ };
+ }
+
+ return {
+ attempted: true,
+ entries: [
+ {
+ name: OPENCODE_GO_PROVIDER_LABEL,
+ group: OPENCODE_GO_PROVIDER_LABEL,
+ label: "Monthly:",
+ percentRemaining: result.percentRemaining,
+ resetTimeIso: result.resetTimeIso,
+ },
+ ],
+ errors: [],
+ };
+ },
+};
diff --git a/src/providers/registry.ts b/src/providers/registry.ts
index 23295d61..209d58c4 100644
--- a/src/providers/registry.ts
+++ b/src/providers/registry.ts
@@ -17,6 +17,7 @@ import { alibabaCodingPlanProvider } from "./alibaba-coding-plan.js";
import { zaiProvider } from "./zai.js";
import { nanoGptProvider } from "./nanogpt.js";
import { minimaxCodingPlanProvider } from "./minimax-coding-plan.js";
+import { opencodeGoProvider } from "./opencode-go.js";
export function getProviders(): QuotaProvider[] {
// Order here defines display ordering in the toast.
@@ -33,5 +34,6 @@ export function getProviders(): QuotaProvider[] {
zaiProvider,
nanoGptProvider,
minimaxCodingPlanProvider,
+ opencodeGoProvider,
];
}
diff --git a/tests/lib.provider-metadata.test.ts b/tests/lib.provider-metadata.test.ts
index f070af75..5c82e2d6 100644
--- a/tests/lib.provider-metadata.test.ts
+++ b/tests/lib.provider-metadata.test.ts
@@ -90,6 +90,13 @@ describe("provider-metadata", () => {
authentication: "opencode_auth_api_key",
quota: "remote_api",
},
+ {
+ id: "opencode-go",
+ autoSetup: "needs_quick_setup",
+ authentication: "state_only",
+ quota: "remote_api",
+ notes: "Scrapes the OpenCode Go dashboard; requires workspaceId and authCookie",
+ },
]);
});
diff --git a/tests/lib.quota-status.test.ts b/tests/lib.quota-status.test.ts
index 29ede380..3ececaf4 100644
--- a/tests/lib.quota-status.test.ts
+++ b/tests/lib.quota-status.test.ts
@@ -126,6 +126,24 @@ vi.mock("../src/lib/opencode-runtime-paths.js", () => ({
cacheDir: "/tmp/cache",
stateDir: "/tmp/state",
}),
+ getOpencodeRuntimeDirCandidates: () => ({
+ configDirs: ["/tmp/config"],
+ }),
+}));
+
+vi.mock("../src/lib/opencode-go-config.js", () => ({
+ getOpenCodeGoConfigDiagnostics: vi.fn(async () => ({
+ state: "none",
+ source: null,
+ missing: null,
+ checkedPaths: [],
+ })),
+ resolveOpenCodeGoConfigCached: vi.fn(async () => ({ state: "none" })),
+ DEFAULT_OPENCODE_GO_CONFIG_CACHE_MAX_AGE_MS: 30_000,
+}));
+
+vi.mock("../src/lib/opencode-go.js", () => ({
+ queryOpenCodeGoQuota: vi.fn(async () => null),
}));
vi.mock("../src/lib/google-token-cache.js", () => ({
@@ -418,9 +436,7 @@ describe("buildQuotaStatusReport", () => {
expect(report).toContain("- auth_status: authenticated");
expect(report).toContain("- quota_supported: false");
expect(report).toContain("- quota_source: (none)");
- expect(report).toContain(
- "- checked_commands: claude --version | claude auth status --json",
- );
+ expect(report).toContain("- checked_commands: claude --version | claude auth status --json");
expect(report).toContain(
"- message: Claude CLI auth detected, but local quota windows were not exposed.",
);
@@ -517,12 +533,8 @@ describe("buildQuotaStatusReport", () => {
expect(report).toContain("- cli_version: 1.2.4");
expect(report).toContain("- quota_supported: true");
expect(report).toContain("- quota_source: claude-auth-status-json");
- expect(report).toContain(
- "- five_hour_remaining: 43% reset_at=2026-03-25T18:00:00.000Z",
- );
- expect(report).toContain(
- "- seven_day_remaining: 88% reset_at=2026-04-01T00:00:00.000Z",
- );
+ expect(report).toContain("- five_hour_remaining: 43% reset_at=2026-03-25T18:00:00.000Z");
+ expect(report).toContain("- seven_day_remaining: 88% reset_at=2026-04-01T00:00:00.000Z");
});
it("reports NanoGPT live subscription and balance diagnostics when configured", async () => {
@@ -659,7 +671,7 @@ describe("buildQuotaStatusReport", () => {
state: "invalid",
source: "auth.json",
checkedPaths: ["/tmp/auth.json"],
- error: "Unsupported MiniMax auth type: \"oauth\"",
+ error: 'Unsupported MiniMax auth type: "oauth"',
});
const invalidReport = await buildMiniMaxStatusReport();
@@ -826,6 +838,8 @@ describe("buildQuotaStatusReport", () => {
expect(report).toContain(
"- remaining_quota_note: valid enterprise billing access can query pooled enterprise usage, but it does not provide a true per-user remaining quota",
);
- expect(report).toContain("- token_compatibility_error: GitHub's enterprise premium usage endpoint does not support fine-grained personal access tokens.");
+ expect(report).toContain(
+ "- token_compatibility_error: GitHub's enterprise premium usage endpoint does not support fine-grained personal access tokens.",
+ );
});
});
diff --git a/tests/providers.opencode-go.test.ts b/tests/providers.opencode-go.test.ts
new file mode 100644
index 00000000..586d8862
--- /dev/null
+++ b/tests/providers.opencode-go.test.ts
@@ -0,0 +1,217 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import {
+ expectAttemptedWithErrorLabel,
+ expectAttemptedWithNoErrors,
+ expectNotAttempted,
+} from "./helpers/provider-assertions.js";
+
+const mocks = vi.hoisted(() => ({
+ fetchWithTimeout: vi.fn(),
+ resolveOpenCodeGoConfigCached: vi.fn(),
+}));
+
+vi.mock("../src/lib/opencode-go-config.js", () => ({
+ resolveOpenCodeGoConfigCached: mocks.resolveOpenCodeGoConfigCached,
+ DEFAULT_OPENCODE_GO_CONFIG_CACHE_MAX_AGE_MS: 30_000,
+}));
+
+vi.mock("../src/lib/http.js", () => ({
+ fetchWithTimeout: mocks.fetchWithTimeout,
+}));
+
+import { opencodeGoProvider } from "../src/providers/opencode-go.js";
+import { _parseMonthlyUsage } from "../src/lib/opencode-go.js";
+
+function mockConfigNone() {
+ mocks.resolveOpenCodeGoConfigCached.mockResolvedValueOnce({ state: "none" });
+}
+
+function mockConfigIncomplete(source = "env", missing = "OPENCODE_GO_AUTH_COOKIE") {
+ mocks.resolveOpenCodeGoConfigCached.mockResolvedValueOnce({
+ state: "incomplete",
+ source,
+ missing,
+ });
+}
+
+function mockConfigConfigured(workspaceId = "ws-123", authCookie = "cookie-abc") {
+ mocks.resolveOpenCodeGoConfigCached.mockResolvedValueOnce({
+ state: "configured",
+ config: { workspaceId, authCookie },
+ source: "env",
+ });
+}
+
+function buildDashboardHtml(usagePercent: number, resetInSec: number): string {
+ return ``;
+}
+
+function buildDashboardHtmlResetFirst(usagePercent: number, resetInSec: number): string {
+ return ``;
+}
+
+function mockDashboardSuccess(html: string) {
+ mocks.fetchWithTimeout.mockResolvedValueOnce({
+ ok: true,
+ text: async () => html,
+ });
+}
+
+function mockDashboardHttpFailure(status: number, text: string) {
+ mocks.fetchWithTimeout.mockResolvedValueOnce({
+ ok: false,
+ status,
+ text: async () => text,
+ });
+}
+
+async function runProviderFetch() {
+ return opencodeGoProvider.fetch({ config: {} } as any);
+}
+
+describe("opencode-go provider", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("returns attempted:false when config is none", async () => {
+ mockConfigNone();
+ const out = await runProviderFetch();
+ expectNotAttempted(out);
+ });
+
+ it("returns error when config is incomplete", async () => {
+ mockConfigIncomplete();
+ const out = await runProviderFetch();
+ expectAttemptedWithErrorLabel(out, "OpenCode Go");
+ expect(out.errors[0]?.message).toContain("OPENCODE_GO_AUTH_COOKIE");
+ });
+
+ it("returns usage entry on successful dashboard scrape", async () => {
+ mockConfigConfigured();
+ mockDashboardSuccess(buildDashboardHtml(42, 1209600));
+
+ const out = await runProviderFetch();
+
+ expectAttemptedWithNoErrors(out);
+ expect(out.entries).toHaveLength(1);
+ expect(out.entries[0]).toMatchObject({
+ name: "OpenCode Go",
+ group: "OpenCode Go",
+ label: "Monthly:",
+ percentRemaining: 58,
+ });
+ expect(out.entries[0]).toHaveProperty("resetTimeIso");
+ });
+
+ it("parses resetInSec-first field order", async () => {
+ mockConfigConfigured();
+ mockDashboardSuccess(buildDashboardHtmlResetFirst(75, 604800));
+
+ const out = await runProviderFetch();
+
+ expectAttemptedWithNoErrors(out);
+ expect(out.entries).toHaveLength(1);
+ expect(out.entries[0]).toMatchObject({
+ percentRemaining: 25,
+ });
+ });
+
+ it("returns error on HTTP failure", async () => {
+ mockConfigConfigured();
+ mockDashboardHttpFailure(403, "Forbidden");
+
+ const out = await runProviderFetch();
+ expectAttemptedWithErrorLabel(out, "OpenCode Go");
+ expect(out.errors[0]?.message).toContain("403");
+ });
+
+ it("returns error when dashboard HTML does not contain usage data", async () => {
+ mockConfigConfigured();
+ mockDashboardSuccess("No usage data here");
+
+ const out = await runProviderFetch();
+ expectAttemptedWithErrorLabel(out, "OpenCode Go");
+ expect(out.errors[0]?.message).toContain("Could not parse");
+ });
+
+ it("returns error on network failure", async () => {
+ mockConfigConfigured();
+ mocks.fetchWithTimeout.mockRejectedValueOnce(new Error("network timeout"));
+
+ const out = await runProviderFetch();
+ expectAttemptedWithErrorLabel(out, "OpenCode Go");
+ expect(out.errors[0]?.message).toContain("network timeout");
+ });
+
+ it("clamps usagePercent to [0, 100]", async () => {
+ mockConfigConfigured();
+ mockDashboardSuccess(buildDashboardHtml(150, 100));
+
+ const out = await runProviderFetch();
+ expectAttemptedWithNoErrors(out);
+ expect(out.entries[0]).toMatchObject({ percentRemaining: 0 });
+ });
+
+ it("sanitizes error text from dashboard responses", async () => {
+ mockConfigConfigured();
+ mockDashboardHttpFailure(500, "\u001b[31mInternal Error\nretry\u001b[0m");
+
+ const out = await runProviderFetch();
+ expectAttemptedWithErrorLabel(out, "OpenCode Go");
+ expect(out.errors[0]?.message).toBe("OpenCode Go dashboard error 500: Internal Error retry");
+ });
+});
+
+describe("opencode-go matchesCurrentModel", () => {
+ it.each([
+ ["opencode-go/some-model", true],
+ ["opencode-go-subscription/any", true],
+ ["openai/gpt-4", false],
+ ["copilot/gpt-4", false],
+ ])("matchesCurrentModel(%s) -> %s", (model, expected) => {
+ expect(opencodeGoProvider.matchesCurrentModel?.(model)).toBe(expected);
+ });
+});
+
+describe("opencode-go isAvailable", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it.each([
+ [{ state: "configured", config: { workspaceId: "ws", authCookie: "ck" }, source: "env" }, true],
+ [{ state: "incomplete", source: "env", missing: "authCookie" }, true],
+ [{ state: "none" }, false],
+ ])("returns correct availability for config state %j", async (configState, expected) => {
+ mocks.resolveOpenCodeGoConfigCached.mockResolvedValueOnce(configState);
+ const available = await opencodeGoProvider.isAvailable({} as any);
+ expect(available).toBe(expected);
+ });
+});
+
+describe("_parseMonthlyUsage", () => {
+ it("returns null for empty string", () => {
+ expect(_parseMonthlyUsage("")).toBeNull();
+ });
+
+ it("parses usagePercent-first ordering", () => {
+ const html = "monthlyUsage:$R[42]={usagePercent:55,resetInSec:3600}";
+ expect(_parseMonthlyUsage(html)).toEqual({ usagePercent: 55, resetInSec: 3600 });
+ });
+
+ it("parses resetInSec-first ordering", () => {
+ const html = "monthlyUsage:$R[7]={resetInSec:7200,usagePercent:30}";
+ expect(_parseMonthlyUsage(html)).toEqual({ usagePercent: 30, resetInSec: 7200 });
+ });
+
+ it("returns null when pattern is missing", () => {
+ expect(_parseMonthlyUsage("hello")).toBeNull();
+ });
+
+ it("handles extra fields in the object", () => {
+ const html = "monthlyUsage:$R[1]={usagePercent:10,foo:bar,resetInSec:500}";
+ expect(_parseMonthlyUsage(html)).toEqual({ usagePercent: 10, resetInSec: 500 });
+ });
+});