diff --git a/open-sse/providers/registry/opencode-go.js b/open-sse/providers/registry/opencode-go.js index c980cc855a0..77299774740 100644 --- a/open-sse/providers/registry/opencode-go.js +++ b/open-sse/providers/registry/opencode-go.js @@ -13,7 +13,7 @@ export default { textIcon: "OC", website: "https://opencode.ai/auth", notice: { - text: "OpenCode Go subscription: $5/mo (then 0/mo). Access to Kimi, GLM, Qwen, MiMo, MiniMax models.", + text: "OpenCode Go subscription: $5 first month, then $10/mo. Access to Kimi, GLM, Qwen, MiMo, MiniMax models.", apiKeyUrl: "https://opencode.ai/auth", }, }, @@ -21,6 +21,9 @@ export default { transport: { baseUrl: "https://opencode.ai/zen/go/v1/chat/completions", headers: {}, + usage: { + url: "https://opencode.ai/zen/go/v1/usage", + }, }, models: [ { id: "glm-5.2", name: "GLM 5.2" }, @@ -38,4 +41,11 @@ export default { { id: "qwen3.7-plus", name: "Qwen 3.7 Plus", targetFormat: "claude" }, { id: "qwen3.6-plus", name: "Qwen 3.6 Plus", targetFormat: "claude" }, ], + features: { + // Go is a subscription with rolling/weekly/monthly windows, but it authenticates + // with a plain API key (category "apikey"), so usageApikey is required for the + // /api/usage route to accept the connection. + usage: true, + usageApikey: true, + }, }; diff --git a/open-sse/services/usage.js b/open-sse/services/usage.js index 70a38ea6d70..1580b322a91 100644 --- a/open-sse/services/usage.js +++ b/open-sse/services/usage.js @@ -14,6 +14,7 @@ import { getCodeBuddyCnUsage, getCodeBuddyIntlUsage } from "./usage/codebuddy-cn import { getGrokCliUsage } from "./usage/grok-cli.js"; import { getKimiUsage } from "./usage/kimi.js"; import { getDeepseekUsage } from "./usage/deepseek.js"; +import { getOpenCodeGoUsage } from "./usage/opencode-go.js"; import { resolveQoderCredentials } from "./qoderModels.js"; import { getIflowUsage, @@ -54,6 +55,7 @@ const USAGE_HANDLERS = { "grok-cli": (c) => getGrokCliUsage(c.accessToken, c.providerSpecificData, c.proxyOptions), kimi: (c) => getKimiUsage(c.accessToken, c.apiKey, c.proxyOptions, c.providerSpecificData), deepseek: (c) => getDeepseekUsage(c.apiKey, c.proxyOptions), + "opencode-go": (c) => getOpenCodeGoUsage(c.apiKey, c.proxyOptions), }; export async function getUsageForProvider(connection, proxyOptions = null) { diff --git a/open-sse/services/usage/opencode-go.js b/open-sse/services/usage/opencode-go.js new file mode 100644 index 00000000000..47de4f5836b --- /dev/null +++ b/open-sse/services/usage/opencode-go.js @@ -0,0 +1,125 @@ +/** + * OpenCode Go usage — GET https://opencode.ai/zen/go/v1/usage + * Auth: Bearer (same Go key used for chat/completions — no session cookie) + * + * Shipped 2026-08-11 (anomalyco/opencode#16513, reshaped by d4704347). Not in the + * public docs yet, so the response shape below is taken from the deployed endpoint: + * + * { "usage": { + * "rolling": { "status": "ok", "percent": 0, "resetsAt": "2026-08-12T20:23:13.083Z" }, + * "weekly": { "status": "ok", "percent": 0, "resetsAt": "2026-08-17T00:00:00.083Z" }, + * "monthly": { "status": "ok", "percent": 91, "resetsAt": "2026-08-25T01:33:44.083Z" } } } + * + * `status` is "ok" | "rate-limited"; `percent` is a floored integer 0-100. The server + * exposes no dollar amounts — it holds limits in micro-cents internally and only ever + * returns the percentage, so these quotas are percent-only. + */ + +import { proxyAwareFetch } from "../../utils/proxyFetch.js"; +import { U, parseResetTime, toFiniteNumber } from "./shared.js"; + +const USAGE_URL = U("opencode-go").url; + +// Provider's own vocabulary from the workspace console. The rolling window length +// is server-configured (limits.rollingWindow) and is NOT echoed in the response, +// so don't label it "(5h)" — only the absolute resetsAt is trustworthy. +const WINDOW_LABELS = { + rolling: "Rolling", + weekly: "Weekly", + monthly: "Monthly", +}; + +// Returns null when the window carries no usable percent. Defaulting a missing +// percent to 0 would publish the window as 100% remaining, i.e. malformed data +// would read as full headroom — the most dangerous direction to be wrong in for +// a quota display. Parsed via toFiniteNumber so a numeric string still counts. +function formatWindow(window) { + const percent = toFiniteNumber(window?.percent, NaN); + if (!Number.isFinite(percent)) return null; + + const used = Math.max(0, Math.min(100, percent)); + return { + used, + total: 100, + // Percent-only: never set absolute `remaining` — QuotaTable reads it as a + // 0-100 percentage (same trap as Qoder/grok-cli). + remainingPercentage: 100 - used, + resetAt: parseResetTime(window?.resetsAt ?? null), + unlimited: false, + }; +} + +/** + * @param {string|null|undefined} apiKey + * @param {object|null} proxyOptions + */ +export async function getOpenCodeGoUsage(apiKey = null, proxyOptions = null) { + if (!apiKey || typeof apiKey !== "string" || !apiKey.trim()) { + return { message: "OpenCode Go API key not available. Add a key to view usage." }; + } + + try { + const response = await proxyAwareFetch( + USAGE_URL, + { + method: "GET", + headers: { + Authorization: `Bearer ${apiKey.trim()}`, + Accept: "application/json", + }, + }, + proxyOptions, + ); + + const data = await response.json().catch(() => null); + // Errors come back as { type: "error", error: { type, message } } + const errorType = data?.error?.type || null; + const errorMessage = data?.error?.message || null; + + if (response.status === 401) { + return { message: errorMessage || "OpenCode Go authentication failed. Check the API key." }; + } + + // A valid key with no Go subscription 403s — distinct from an auth failure, + // and not something re-authorizing will fix. + if (response.status === 403 || errorType === "EntitlementError") { + return { + plan: "OpenCode Go", + message: errorMessage || "OpenCode Go subscription required.", + }; + } + + if (!response.ok) { + return { + plan: "OpenCode Go", + message: `OpenCode Go usage API error (${response.status})${errorMessage ? `: ${errorMessage}` : ""}`, + }; + } + + const usage = data?.usage; + if (!usage || typeof usage !== "object") { + return { message: "OpenCode Go usage response was not in the expected shape." }; + } + + const quotas = {}; + let limitReached = false; + for (const [key, label] of Object.entries(WINDOW_LABELS)) { + const window = usage[key]; + if (!window || typeof window !== "object") continue; + // Check status before the percent gate: "rate-limited" is authoritative on + // its own, so a window with a missing percent must still raise the flag. + if (window.status === "rate-limited") limitReached = true; + const quota = formatWindow(window); + if (!quota) continue; + quotas[label] = quota; + } + + if (Object.keys(quotas).length === 0) { + return { plan: "OpenCode Go", message: "OpenCode Go connected. No usage windows reported.", quotas: {} }; + } + + return { plan: "OpenCode Go", limitReached, quotas }; + } catch (error) { + return { message: `OpenCode Go error: ${error.message}` }; + } +} diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js index 9f185b83f04..7977e9e948c 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js @@ -538,6 +538,22 @@ export function parseQuotaData(provider, data) { } break; + case "opencode-go": + // Go subscription windows (Rolling / Weekly / Monthly) as usage %. + // remainingPercentage only — no absolute remaining (UI treats remaining as %). + if (data.quotas) { + Object.entries(data.quotas).forEach(([name, quota]) => { + normalizedQuotas.push({ + name, + used: quota.used || 0, + total: quota.total || 0, + resetAt: quota.resetAt || null, + remainingPercentage: quota.remainingPercentage, + }); + }); + } + break; + default: // Generic fallback for unknown providers if (data.quotas) { diff --git a/tests/unit/opencode-go-usage.test.js b/tests/unit/opencode-go-usage.test.js new file mode 100644 index 00000000000..788863ae031 --- /dev/null +++ b/tests/unit/opencode-go-usage.test.js @@ -0,0 +1,191 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../../open-sse/utils/proxyFetch.js", () => ({ + proxyAwareFetch: vi.fn(), +})); + +import { proxyAwareFetch } from "../../open-sse/utils/proxyFetch.js"; +import { getUsageForProvider } from "../../open-sse/services/usage.js"; +import { + USAGE_SUPPORTED_PROVIDERS, + USAGE_APIKEY_PROVIDERS, +} from "../../src/shared/constants/providers.js"; +import { + parseQuotaData, + getRemainingPercentage, +} from "../../src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js"; + +const USAGE_URL = "https://opencode.ai/zen/go/v1/usage"; + +function jsonResponse(body, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +// Captured verbatim from the live endpoint on 2026-08-12, the day after +// anomalyco/opencode#16513 shipped. The endpoint is still undocumented, so this +// payload — not the PR description or the issue proposals — is the contract. +const SAMPLE_USAGE = { + usage: { + rolling: { status: "ok", percent: 0, resetsAt: "2026-08-12T20:23:13.083Z" }, + weekly: { status: "ok", percent: 0, resetsAt: "2026-08-17T00:00:00.083Z" }, + monthly: { status: "ok", percent: 91, resetsAt: "2026-08-25T01:33:44.083Z" }, + }, +}; + +const conn = { provider: "opencode-go", apiKey: "sk-test" }; + +describe("opencode-go usage", () => { + beforeEach(() => { + proxyAwareFetch.mockReset(); + }); + + it("is registered as an apikey-eligible usage provider", () => { + expect(USAGE_SUPPORTED_PROVIDERS).toContain("opencode-go"); + // Go authenticates with a plain API key, so the /api/usage route only + // accepts the connection when usageApikey is also set. + expect(USAGE_APIKEY_PROVIDERS).toContain("opencode-go"); + }); + + it("calls the usage endpoint with bearer auth", async () => { + proxyAwareFetch.mockResolvedValue(jsonResponse(SAMPLE_USAGE)); + await getUsageForProvider(conn); + + const [url, options] = proxyAwareFetch.mock.calls[0]; + expect(url).toBe(USAGE_URL); + expect(options.headers.Authorization).toBe("Bearer sk-test"); + }); + + it("maps the three windows to percent-based quotas", async () => { + proxyAwareFetch.mockResolvedValue(jsonResponse(SAMPLE_USAGE)); + const usage = await getUsageForProvider(conn); + + expect(usage.plan).toBe("OpenCode Go"); + expect(usage.limitReached).toBe(false); + expect(Object.keys(usage.quotas)).toEqual(["Rolling", "Weekly", "Monthly"]); + expect(usage.quotas.Monthly).toEqual({ + used: 91, + total: 100, + remainingPercentage: 9, + resetAt: "2026-08-25T01:33:44.083Z", + unlimited: false, + }); + }); + + it("renders remaining percentage the right way round in the dashboard", async () => { + proxyAwareFetch.mockResolvedValue(jsonResponse(SAMPLE_USAGE)); + const usage = await getUsageForProvider(conn); + const rows = parseQuotaData("opencode-go", usage); + + const monthly = rows.find((r) => r.name === "Monthly"); + // 91% used must read as 9% remaining, not 91%. + expect(getRemainingPercentage(monthly)).toBe(9); + // Absolute `remaining` must stay unset — the UI treats it as a 0-100 percent. + expect(monthly.remaining).toBeUndefined(); + expect(getRemainingPercentage(rows.find((r) => r.name === "Rolling"))).toBe(100); + }); + + it("flags limitReached when any window is rate-limited", async () => { + proxyAwareFetch.mockResolvedValue( + jsonResponse({ + usage: { + ...SAMPLE_USAGE.usage, + monthly: { status: "rate-limited", percent: 100, resetsAt: "2026-08-25T01:33:44.083Z" }, + }, + }), + ); + const usage = await getUsageForProvider(conn); + + expect(usage.limitReached).toBe(true); + expect(usage.quotas.Monthly.remainingPercentage).toBe(0); + }); + + it("drops a window with no usable percent rather than showing it as full", async () => { + proxyAwareFetch.mockResolvedValue( + jsonResponse({ + usage: { + ...SAMPLE_USAGE.usage, + weekly: { status: "ok", resetsAt: "2026-08-17T00:00:00.083Z" }, // percent missing + }, + }), + ); + const usage = await getUsageForProvider(conn); + + // Defaulting the missing percent to 0 would publish "100% remaining" and + // tell the user they have headroom they may not have. + expect(usage.quotas).not.toHaveProperty("Weekly"); + expect(Object.keys(usage.quotas)).toEqual(["Rolling", "Monthly"]); + expect(parseQuotaData("opencode-go", usage).find((r) => r.name === "Weekly")).toBeUndefined(); + }); + + it("still flags limitReached when a rate-limited window omits percent", async () => { + proxyAwareFetch.mockResolvedValue( + jsonResponse({ + usage: { + ...SAMPLE_USAGE.usage, + monthly: { status: "rate-limited", resetsAt: "2026-08-25T01:33:44.083Z" }, + }, + }), + ); + const usage = await getUsageForProvider(conn); + + // status is authoritative on its own — dropping the unusable percent must + // not also discard the "you are throttled" signal. + expect(usage.quotas).not.toHaveProperty("Monthly"); + expect(usage.limitReached).toBe(true); + }); + + it("accepts a numeric string percent", async () => { + proxyAwareFetch.mockResolvedValue( + jsonResponse({ usage: { monthly: { status: "ok", percent: "91", resetsAt: null } } }), + ); + const usage = await getUsageForProvider(conn); + expect(usage.quotas.Monthly.used).toBe(91); + }); + + it("reports a missing subscription (403) distinctly from an auth failure", async () => { + proxyAwareFetch.mockResolvedValue( + jsonResponse( + { type: "error", error: { type: "EntitlementError", message: "OpenCode Go subscription required." } }, + 403, + ), + ); + const usage = await getUsageForProvider(conn); + + expect(usage.message).toBe("OpenCode Go subscription required."); + // Must NOT read as an auth-expired message — re-authorizing cannot fix it. + expect(usage.message.toLowerCase()).not.toContain("unauthorized"); + expect(usage.quotas).toBeUndefined(); + }); + + it("surfaces a 401 as an auth failure", async () => { + proxyAwareFetch.mockResolvedValue( + jsonResponse({ type: "error", error: { type: "AuthError", message: "Unauthorized" } }, 401), + ); + expect((await getUsageForProvider(conn)).message).toBe("Unauthorized"); + }); + + it("degrades to a message when the endpoint disappears or changes shape", async () => { + // The endpoint is a day old and undocumented: a rollback serves the SPA 404 HTML. + proxyAwareFetch.mockResolvedValue( + new Response("404", { status: 404, headers: { "Content-Type": "text/html" } }), + ); + expect(await getUsageForProvider(conn)).toMatchObject({ plan: "OpenCode Go" }); + expect((await getUsageForProvider(conn)).quotas).toBeUndefined(); + + proxyAwareFetch.mockResolvedValue(jsonResponse({ unexpected: true })); + expect((await getUsageForProvider(conn)).message).toMatch(/expected shape/); + }); + + it("never throws on a missing key or a network error", async () => { + expect((await getUsageForProvider({ provider: "opencode-go", apiKey: null })).message).toMatch( + /API key not available/, + ); + expect(proxyAwareFetch).not.toHaveBeenCalled(); + + proxyAwareFetch.mockRejectedValue(new Error("boom")); + expect((await getUsageForProvider(conn)).message).toBe("OpenCode Go error: boom"); + }); +});