diff --git a/open-sse/providers/registry/opencode-go.js b/open-sse/providers/registry/opencode-go.js index 0dad175f..06ae7ca1 100644 --- a/open-sse/providers/registry/opencode-go.js +++ b/open-sse/providers/registry/opencode-go.js @@ -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", + }, }, // Multi-endpoint: pick the transport matching the client sourceFormat to skip // translation. Guarded per-model by `supportedFormats` (see chatCore) because @@ -48,4 +51,8 @@ export default { { id: "qwen3.7-plus", name: "Qwen 3.7 Plus", supportedFormats: ["openai", "claude"] }, { id: "qwen3.6-plus", name: "Qwen 3.6 Plus", supportedFormats: ["openai", "claude"] }, ], + features: { + usage: true, + usageApikey: true, + }, }; diff --git a/open-sse/services/usage.js b/open-sse/services/usage.js index 51f00898..eeb46c3c 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 { getGroqUsage } from "./usage/groq.js"; import { getZedUsage } from "./usage/zed.js"; import { resolveQoderCredentials } from "./qoderModels.js"; @@ -55,6 +56,7 @@ const USAGE_HANDLERS = { "codebuddy-intl": (c) => getCodeBuddyIntlUsage(c.accessToken, c.apiKey, c.providerSpecificData, c.proxyOptions), "grok-cli": (c) => getGrokCliUsage(c.accessToken, c.providerSpecificData, c.proxyOptions), kimi: (c) => getKimiUsage(c.accessToken, c.apiKey, c.proxyOptions, c.providerSpecificData), + "opencode-go": (c) => getOpenCodeGoUsage(c.apiKey, c.proxyOptions), deepseek: (c) => getDeepseekUsage(c.apiKey, c.proxyOptions), groq: (c) => getGroqUsage(c.apiKey, c.proxyOptions), zed: (c) => getZedUsage(c.accessToken, c.providerSpecificData, c.proxyOptions), diff --git a/open-sse/services/usage/opencode-go.js b/open-sse/services/usage/opencode-go.js new file mode 100644 index 00000000..2d352498 --- /dev/null +++ b/open-sse/services/usage/opencode-go.js @@ -0,0 +1,107 @@ +/** + * OpenCode Go usage — GET https://opencode.ai/zen/go/v1/usage + * Auth: Bearer + */ + +import { proxyAwareFetch } from "../../utils/proxyFetch.js"; +import { parseResetTime, toFiniteNumber, U } from "./shared.js"; + +const USAGE_URL = U("opencode-go").url; +const QUOTA_NAMES = { + rolling: "Rolling", + weekly: "Weekly", + monthly: "Monthly", +}; + +function parsePercent(value) { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string" && value.trim()) { + const parsed = Number(value); + if (Number.isFinite(parsed)) return parsed; + } + return null; +} + +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, + ); + + if (response.status === 401) { + return { + plan: "OpenCode Go", + message: "OpenCode Go authentication failed. Check the API key.", + }; + } + + if (response.status === 403) { + const error = await response.json().catch(() => null); + const subscriptionRequired = error?.error?.type === "EntitlementError"; + return { + plan: "OpenCode Go", + message: subscriptionRequired + ? "OpenCode Go subscription required for this API key." + : "OpenCode Go access forbidden for this API key.", + }; + } + + if (!response.ok) { + return { + plan: "OpenCode Go", + message: `OpenCode Go usage API error (${response.status}).`, + }; + } + + const data = await response.json().catch(() => null); + if (!data?.usage || typeof data.usage !== "object") { + return { + plan: "OpenCode Go", + message: "OpenCode Go usage response did not contain quota data.", + }; + } + + const quotas = {}; + for (const [period, name] of Object.entries(QUOTA_NAMES)) { + const quota = data.usage[period]; + if (!quota || typeof quota !== "object") continue; + const percent = parsePercent(quota.percent); + if (percent === null) continue; + const used = Math.max(0, Math.min(100, toFiniteNumber(percent, 0))); + quotas[name] = { + used, + total: 100, + remaining: 100 - used, + remainingPercentage: 100 - used, + resetAt: parseResetTime(quota.resetsAt), + unlimited: false, + }; + } + + + if (Object.keys(quotas).length === 0) { + return { + plan: "OpenCode Go", + message: "OpenCode Go usage response did not contain valid quota data.", + }; + } + + return { plan: "OpenCode Go", quotas }; + } catch (error) { + return { message: `OpenCode Go error: ${error.message}` }; + } +} diff --git a/tests/unit/opencode-go-usage.test.js b/tests/unit/opencode-go-usage.test.js new file mode 100644 index 00000000..349554a4 --- /dev/null +++ b/tests/unit/opencode-go-usage.test.js @@ -0,0 +1,129 @@ +import { beforeEach, describe, expect, it, vi } 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_APIKEY_PROVIDERS, + USAGE_SUPPORTED_PROVIDERS, +} from "../../src/shared/constants/providers.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" }, + }); +} + +describe("OpenCode Go registry usage flags", () => { + it("is listed for the API key quota dashboard", () => { + expect(USAGE_SUPPORTED_PROVIDERS).toContain("opencode-go"); + expect(USAGE_APIKEY_PROVIDERS).toContain("opencode-go"); + }); +}); + +describe("getUsageForProvider(opencode-go)", () => { + beforeEach(() => vi.clearAllMocks()); + + it("fetches and normalizes subscription usage", async () => { + proxyAwareFetch.mockResolvedValueOnce( + jsonResponse({ + usage: { + rolling: { status: "ok", percent: 13, resetsAt: "2026-09-04T14:28:02.617Z" }, + weekly: { status: "ok", percent: 5, resetsAt: "2026-09-07T00:00:00.617Z" }, + monthly: { status: "ok", percent: 2, resetsAt: "2026-10-02T12:14:24.617Z" }, + }, + }), + ); + + const usage = await getUsageForProvider({ + provider: "opencode-go", + apiKey: "sk-go-test", + }); + + expect(proxyAwareFetch).toHaveBeenCalledWith( + USAGE_URL, + expect.objectContaining({ + method: "GET", + headers: expect.objectContaining({ Authorization: "Bearer sk-go-test" }), + }), + null, + ); + expect(usage).toEqual({ + plan: "OpenCode Go", + quotas: { + Rolling: { + used: 13, + total: 100, + remaining: 87, + remainingPercentage: 87, + resetAt: "2026-09-04T14:28:02.617Z", + unlimited: false, + }, + Weekly: expect.objectContaining({ used: 5, remainingPercentage: 95 }), + Monthly: expect.objectContaining({ used: 2, remainingPercentage: 98 }), + }, + }); + }); + + it("reports missing and rejected credentials", async () => { + const missing = await getUsageForProvider({ provider: "opencode-go" }); + expect(missing.message).toMatch(/api key/i); + expect(proxyAwareFetch).not.toHaveBeenCalled(); + + proxyAwareFetch.mockResolvedValueOnce(jsonResponse({ error: "unauthorized" }, 401)); + const rejected = await getUsageForProvider({ + provider: "opencode-go", + apiKey: "bad", + }); + expect(rejected.message).toMatch(/authentication failed/i); + }); + + it("distinguishes a missing subscription from invalid credentials", async () => { + proxyAwareFetch.mockResolvedValueOnce( + jsonResponse({ error: { type: "EntitlementError" } }, 403), + ); + + const usage = await getUsageForProvider({ + provider: "opencode-go", + apiKey: "sk-without-go", + }); + + expect(usage.message).toMatch(/subscription required/i); + }); + + it("rejects responses without a valid quota percentage", async () => { + proxyAwareFetch.mockResolvedValueOnce( + jsonResponse({ usage: { rolling: { status: "ok" }, future: { percent: 10 } } }), + ); + + const usage = await getUsageForProvider({ + provider: "opencode-go", + apiKey: "sk-go-test", + }); + + expect(usage.quotas).toBeUndefined(); + expect(usage.message).toMatch(/valid quota data/i); + }); + + it("reports upstream and network failures", async () => { + proxyAwareFetch.mockResolvedValueOnce(jsonResponse({ error: "unavailable" }, 500)); + const upstream = await getUsageForProvider({ + provider: "opencode-go", + apiKey: "sk-go-test", + }); + expect(upstream.message).toContain("500"); + + proxyAwareFetch.mockRejectedValueOnce(new Error("socket closed")); + const network = await getUsageForProvider({ + provider: "opencode-go", + apiKey: "sk-go-test", + }); + expect(network.message).toContain("socket closed"); + }); +}); diff --git a/tests/unit/usage-dispatch.test.js b/tests/unit/usage-dispatch.test.js index 5b86ee9e..84a5f469 100644 --- a/tests/unit/usage-dispatch.test.js +++ b/tests/unit/usage-dispatch.test.js @@ -16,7 +16,7 @@ const SUPPORTED = [ "github", "gemini-cli", "antigravity", "claude", "codex", "kiro", "qoder", "iflow", "ollama", "glm", "glm-cn", "minimax", "minimax-cn", "vercel-ai-gateway", "grok-cli", "kimi", - "deepseek", "zed", + "deepseek", "opencode-go", "zed", ]; describe("usage dispatch", () => {