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: {
|
transport: {
|
||||||
baseUrl: "https://opencode.ai/zen/go/v1/chat/completions",
|
baseUrl: "https://opencode.ai/zen/go/v1/chat/completions",
|
||||||
headers: {},
|
headers: {},
|
||||||
|
usage: {
|
||||||
|
url: "https://opencode.ai/zen/go/v1/usage",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
// Multi-endpoint: pick the transport matching the client sourceFormat to skip
|
// Multi-endpoint: pick the transport matching the client sourceFormat to skip
|
||||||
// translation. Guarded per-model by `supportedFormats` (see chatCore) because
|
// 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.7-plus", name: "Qwen 3.7 Plus", supportedFormats: ["openai", "claude"] },
|
||||||
{ id: "qwen3.6-plus", name: "Qwen 3.6 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 { getGrokCliUsage } from "./usage/grok-cli.js";
|
||||||
import { getKimiUsage } from "./usage/kimi.js";
|
import { getKimiUsage } from "./usage/kimi.js";
|
||||||
import { getDeepseekUsage } from "./usage/deepseek.js";
|
import { getDeepseekUsage } from "./usage/deepseek.js";
|
||||||
|
import { getOpenCodeGoUsage } from "./usage/opencode-go.js";
|
||||||
import { getGroqUsage } from "./usage/groq.js";
|
import { getGroqUsage } from "./usage/groq.js";
|
||||||
import { getZedUsage } from "./usage/zed.js";
|
import { getZedUsage } from "./usage/zed.js";
|
||||||
import { resolveQoderCredentials } from "./qoderModels.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),
|
"codebuddy-intl": (c) => getCodeBuddyIntlUsage(c.accessToken, c.apiKey, c.providerSpecificData, c.proxyOptions),
|
||||||
"grok-cli": (c) => getGrokCliUsage(c.accessToken, 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),
|
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),
|
deepseek: (c) => getDeepseekUsage(c.apiKey, c.proxyOptions),
|
||||||
groq: (c) => getGroqUsage(c.apiKey, c.proxyOptions),
|
groq: (c) => getGroqUsage(c.apiKey, c.proxyOptions),
|
||||||
zed: (c) => getZedUsage(c.accessToken, c.providerSpecificData, 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",
|
"github", "gemini-cli", "antigravity", "claude", "codex", "kiro",
|
||||||
"qoder", "iflow", "ollama", "glm", "glm-cn",
|
"qoder", "iflow", "ollama", "glm", "glm-cn",
|
||||||
"minimax", "minimax-cn", "vercel-ai-gateway", "grok-cli", "kimi",
|
"minimax", "minimax-cn", "vercel-ai-gateway", "grok-cli", "kimi",
|
||||||
"deepseek", "zed",
|
"deepseek", "opencode-go", "zed",
|
||||||
];
|
];
|
||||||
|
|
||||||
describe("usage dispatch", () => {
|
describe("usage dispatch", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user