fix(usage): track OpenCode Go quota (#3791)
OpenCode Go API-key connections now appear in the Quota Tracker and report rolling, weekly, and monthly subscription usage.
This commit is contained in:
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
|
||||
107
open-sse/services/usage/opencode-go.js
Normal file
107
open-sse/services/usage/opencode-go.js
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* OpenCode Go usage — GET https://opencode.ai/zen/go/v1/usage
|
||||
* Auth: Bearer <apiKey>
|
||||
*/
|
||||
|
||||
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}` };
|
||||
}
|
||||
}
|
||||
129
tests/unit/opencode-go-usage.test.js
Normal file
129
tests/unit/opencode-go-usage.test.js
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user