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 }); + }); +});