Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 58 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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) |

<a id="anthropic-quick-setup"></a>
<details>
Expand Down Expand Up @@ -178,6 +179,37 @@ For behavior details and troubleshooting, see [Qwen Code notes](#qwen-code-notes

</details>

<a id="opencode-go-quick-setup"></a>
<details>
<summary><strong>Quick setup: OpenCode Go</strong></summary>

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/<workspaceId>/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).

</details>

## Commands

| Command | What it shows |
Expand Down Expand Up @@ -475,6 +507,31 @@ Example user/global config (`~/.config/opencode/opencode.jsonc` on Linux/macOS):

</details>

<a id="opencode-go-notes"></a>
<details>
<summary><strong>OpenCode Go</strong></summary>

OpenCode Go quota scrapes the OpenCode Go dashboard at `https://opencode.ai/workspace/<workspaceId>/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 |

</details>

## Configuration Reference

All plugin settings live under `experimental.quotaToast`.
Expand Down
131 changes: 131 additions & 0 deletions src/lib/opencode-go-config.ts
Original file line number Diff line number Diff line change
@@ -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<Partial<OpenCodeGoConfig> | null> {
try {
const data = await readFile(path, "utf-8");
const parsed = JSON.parse(data) as Record<string, unknown>;
if (!parsed || typeof parsed !== "object") return null;
return parsed as Partial<OpenCodeGoConfig>;
} 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<ResolvedOpenCodeGoConfig> {
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<ResolvedOpenCodeGoConfig> {
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<OpenCodeGoConfigDiagnostics> {
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,
};
}
117 changes: 117 additions & 0 deletions src/lib/opencode-go.ts
Original file line number Diff line number Diff line change
@@ -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<OpenCodeGoResult> {
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 };
Loading