Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions open-sse/providers/registry/opencode-go.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
},
};
2 changes: 2 additions & 0 deletions open-sse/services/usage.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
getOllamaUsage,
getVercelAiGatewayUsage,
getQoderUsage,
getOpencodeGoUsage,
} from "./usage/misc.js";

/**
Expand Down Expand Up @@ -56,6 +57,7 @@ const USAGE_HANDLERS = {
kimi: (c) => getKimiUsage(c.accessToken, c.apiKey, c.proxyOptions, c.providerSpecificData),
deepseek: (c) => getDeepseekUsage(c.apiKey, c.proxyOptions),
zed: (c) => getZedUsage(c.accessToken, c.providerSpecificData, c.proxyOptions),
"opencode-go": (c) => getOpencodeGoUsage(c.apiKey, c.proxyOptions),
};

export async function getUsageForProvider(connection, proxyOptions = null, options = {}) {
Expand Down
110 changes: 108 additions & 2 deletions open-sse/services/usage/misc.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
/**
* Misc usage handlers (iFlow, Ollama, GLM, Vercel AI Gateway, Qoder)
* Misc usage handlers (iFlow, Ollama, GLM, Vercel AI Gateway, Qoder, OpenCode Go)
*/

import { proxyAwareFetch } from "../../utils/proxyFetch.js";
import { U } from "./shared.js";
import { U, parseResetTime } from "./shared.js";

export { getGlmUsage } from "./glm.js";

Expand Down Expand Up @@ -254,3 +254,109 @@ export async function getQoderUsage(accessToken, proxyOptions = null) {
return { message: `Qoder connected. Unable to fetch usage: ${error.message}` };
}
}

// OpenCode Go reports each window as a percentage consumed, not absolute counts,
// so used is the percent and total is 100.
//
// Labels carry no duration on purpose: only the rolling window is a fixed span
// (and its length is server-side plan config, absent from the payload). Weekly
// resets on a calendar week boundary and monthly on the subscription
// anniversary, so "7d"/"30d" would be wrong. Each row renders its own countdown
// from resetAt anyway.
const OPENCODE_GO_WINDOWS = [
{ key: "rolling", label: "Rolling" },
{ key: "weekly", label: "Weekly" },
{ key: "monthly", label: "Monthly" },
];

// Errors come back as {type:"error", error:{type, message}} — surface the
// message rather than echoing the raw JSON envelope at the user.
async function readOpencodeGoError(response) {
const text = await response.text().catch(() => "");
if (!text) return "";
try {
const message = JSON.parse(text)?.error?.message;
if (typeof message === "string" && message.trim()) return message.trim();
} catch {
// not JSON — fall through to the raw text
}
return text.slice(0, 200);
}

/**
* OpenCode Go Usage
*/
export async function getOpencodeGoUsage(apiKey, proxyOptions = null) {
if (!apiKey) {
return { message: "OpenCode Go API key not available." };
}

const url = U("opencode-go").url;
if (!url) {
return { message: "OpenCode Go usage endpoint is not configured." };
}

try {
const response = await proxyAwareFetch(url, {
method: "GET",
headers: {
Authorization: `Bearer ${apiKey}`,
Accept: "application/json",
},
}, proxyOptions);

if (response.status === 401) {
return { message: "OpenCode Go API key invalid or expired." };
}

// 403 is an entitlement error, not an auth error: upstream answers it when
// the key authenticates but the account carries no Go subscription. Calling
// that an invalid key would send the user off to reissue a working one.
if (response.status === 403) {
const detail = await readOpencodeGoError(response);
return {
plan: "OpenCode Go",
message: detail || "OpenCode Go subscription required for this account.",
};
}

if (!response.ok) {
const detail = await readOpencodeGoError(response);
return { message: `OpenCode Go usage API error (${response.status})${detail ? `: ${detail}` : ""}` };
}

const data = await response.json().catch(() => null);
if (!data || typeof data !== "object") {
return { message: "OpenCode Go usage response was not JSON." };
}

const usage = data?.usage || {};
const quotas = {};

for (const { key, label } of OPENCODE_GO_WINDOWS) {
const window = usage[key];
// Upstream emits all three windows today, but the payload shape has moved
// once already. Skip anything unrecognised instead of emitting a 0% bar
// that would read as "quota untouched".
if (!window || typeof window !== "object") continue;
const percent = Number(window.percent);
if (!Number.isFinite(percent)) continue;
const used = Math.min(100, Math.max(0, percent));
quotas[label] = {
used,
total: 100,
remainingPercentage: 100 - used,
resetAt: parseResetTime(window.resetsAt),
unlimited: false,
};
}

if (Object.keys(quotas).length === 0) {
return { plan: "OpenCode Go", message: "OpenCode Go connected. No quota windows reported.", quotas: {} };
}

return { plan: "OpenCode Go", quotas };
} catch (error) {
return { message: `OpenCode Go connected. Unable to fetch usage: ${error.message}` };
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -527,8 +527,13 @@ export function parseQuotaData(provider, data) {
}
break;

case "opencode-go":
case "deepseek":
// Credit balance — remainingPercentage only (no absolute remaining).
// Credit balance, and OpenCode Go's percent-per-window: forward
// remainingPercentage and never an absolute `remaining` (the UI reads
// `remaining` as a 0-100 percentage). For a used=percent/total=100 row
// the default branch happens to compute the same number, so this case
// is for intent and for staying correct if the shape ever changes.
if (data.quotas) {
Object.entries(data.quotas).forEach(([name, quota]) => {
normalizedQuotas.push({
Expand Down
197 changes: 197 additions & 0 deletions tests/unit/opencode-go-usage-3334.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
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" },
});
}

// Mirrors the upstream route (anomalyco/opencode
// packages/console/app/src/routes/zen/go/v1/usage.ts): every window carries
// status + a floored 0..100 percent + an ISO resetsAt, and all three are
// always emitted.
const FULL_USAGE = {
usage: {
rolling: { status: "ok", percent: 12, resetsAt: "2026-08-15T18:00:00.000Z" },
weekly: { status: "ok", percent: 47, resetsAt: "2026-08-20T00:00:00.000Z" },
monthly: { status: "rate-limited", percent: 100, resetsAt: "2026-09-01T00:00:00.000Z" },
},
};

describe("opencode-go registry usage flags", () => {
it("is listed for the apikey quota dashboard", () => {
expect(USAGE_SUPPORTED_PROVIDERS).toContain("opencode-go");
expect(USAGE_APIKEY_PROVIDERS).toContain("opencode-go");
});

it("carries the usage endpoint in the registry, not in the fetcher", async () => {
const registry = (await import("../../open-sse/providers/registry/opencode-go.js")).default;
expect(registry.transport.usage.url).toBe(USAGE_URL);
});
});

describe("getUsageForProvider(opencode-go)", () => {
beforeEach(() => vi.clearAllMocks());

it("GETs the usage endpoint with the Bearer apiKey", async () => {
proxyAwareFetch.mockResolvedValueOnce(jsonResponse(FULL_USAGE));

const usage = await getUsageForProvider({ provider: "opencode-go", apiKey: "oc-test" });

expect(usage.message).toBeUndefined();
expect(usage.plan).toBe("OpenCode Go");
expect(proxyAwareFetch).toHaveBeenCalledTimes(1);
const [url, opts] = proxyAwareFetch.mock.calls[0];
expect(url).toBe(USAGE_URL);
expect(opts.method).toBe("GET");
expect(opts.headers.Authorization).toBe("Bearer oc-test");
});

it("maps each window to a percent bar with the remainder derived", async () => {
proxyAwareFetch.mockResolvedValueOnce(jsonResponse(FULL_USAGE));

const usage = await getUsageForProvider({ provider: "opencode-go", apiKey: "oc-test" });

expect(usage.quotas.Rolling).toMatchObject({ used: 12, total: 100, remainingPercentage: 88 });
expect(usage.quotas.Weekly).toMatchObject({ used: 47, total: 100, remainingPercentage: 53 });
expect(usage.quotas.Monthly).toMatchObject({ used: 100, total: 100, remainingPercentage: 0 });
expect(usage.quotas.Weekly.resetAt).toBe("2026-08-20T00:00:00.000Z");
});

// Labels stay duration-free: weekly resets on a calendar week boundary and
// monthly on the subscription anniversary, so "7d"/"30d" would be wrong.
it("labels windows without asserting a span", async () => {
proxyAwareFetch.mockResolvedValueOnce(jsonResponse(FULL_USAGE));

const usage = await getUsageForProvider({ provider: "opencode-go", apiKey: "oc-test" });

expect(Object.keys(usage.quotas)).toEqual(["Rolling", "Weekly", "Monthly"]);
});

it("skips a window it cannot read instead of emitting a 0% bar", async () => {
proxyAwareFetch.mockResolvedValueOnce(
jsonResponse({ usage: { rolling: { percent: 5 }, weekly: null, monthly: { percent: "n/a" } } }),
);

const usage = await getUsageForProvider({ provider: "opencode-go", apiKey: "oc-test" });

expect(Object.keys(usage.quotas)).toEqual(["Rolling"]);
expect(usage.quotas.Rolling.resetAt).toBeNull();
});

it("clamps a percentage outside 0..100", async () => {
proxyAwareFetch.mockResolvedValueOnce(jsonResponse({ usage: { weekly: { percent: 130 } } }));

const usage = await getUsageForProvider({ provider: "opencode-go", apiKey: "oc-test" });

expect(usage.quotas.Weekly).toMatchObject({ used: 100, remainingPercentage: 0 });
});

it("reports rather than throws when the payload carries no window", async () => {
proxyAwareFetch.mockResolvedValueOnce(jsonResponse({ usage: {} }));

const usage = await getUsageForProvider({ provider: "opencode-go", apiKey: "oc-test" });

expect(usage.quotas).toEqual({});
expect(usage.message).toMatch(/no quota windows/i);
});

it("returns a message on a missing key and never calls out", async () => {
const missing = await getUsageForProvider({ provider: "opencode-go" });
expect(missing.message).toMatch(/api key/i);
expect(proxyAwareFetch).not.toHaveBeenCalled();
});

it("calls 401 an invalid key", async () => {
proxyAwareFetch.mockResolvedValueOnce(
jsonResponse({ type: "error", error: { type: "AuthError", message: "Unauthorized" } }, 401),
);

const usage = await getUsageForProvider({ provider: "opencode-go", apiKey: "bad" });

expect(usage.message).toMatch(/invalid or expired/i);
});

// Upstream answers 403 EntitlementError when the key is fine but the account
// has no Go plan. Calling that an invalid key sends the user to reissue a
// working one.
it("reports 403 as a missing subscription, not a bad key", async () => {
proxyAwareFetch.mockResolvedValueOnce(
jsonResponse(
{ type: "error", error: { type: "EntitlementError", message: "OpenCode Go subscription required." } },
403,
),
);

const usage = await getUsageForProvider({ provider: "opencode-go", apiKey: "oc-test" });

expect(usage.message).toBe("OpenCode Go subscription required.");
expect(usage.message).not.toMatch(/invalid|expired/i);
});

it("surfaces the upstream error message rather than the JSON envelope", async () => {
proxyAwareFetch.mockResolvedValueOnce(
jsonResponse({ type: "error", error: { type: "ServerError", message: "upstream unavailable" } }, 503),
);

const usage = await getUsageForProvider({ provider: "opencode-go", apiKey: "oc-test" });

expect(usage.message).toContain("503");
expect(usage.message).toContain("upstream unavailable");
expect(usage.message).not.toContain("{");
});

it("returns a message on a non-JSON body and on a transport failure", async () => {
proxyAwareFetch.mockResolvedValueOnce(new Response("<html>gateway</html>", { status: 200 }));
const garbled = await getUsageForProvider({ provider: "opencode-go", apiKey: "oc-test" });
expect(garbled.message).toMatch(/not json/i);

proxyAwareFetch.mockRejectedValueOnce(new Error("socket hang up"));
const down = await getUsageForProvider({ provider: "opencode-go", apiKey: "oc-test" });
expect(down.message).toMatch(/socket hang up/i);
});
});

describe("parseQuotaData(opencode-go)", () => {
const RAW = {
plan: "OpenCode Go",
quotas: {
Rolling: { used: 12, total: 100, remainingPercentage: 88, resetAt: null },
Monthly: { used: 90, total: 100, remainingPercentage: 10, resetAt: null },
},
};

it("forwards remainingPercentage for every window", () => {
const rows = parseQuotaData("opencode-go", RAW);

expect(rows.map((r) => r.name)).toEqual(["Rolling", "Monthly"]);
expect(rows[0]).toMatchObject({ total: 100, remainingPercentage: 88 });
expect(rows[1]).toMatchObject({ total: 100, remainingPercentage: 10 });
});

// The rendered number is what matters, and QuotaTable derives it through
// getRemainingPercentage — pin that, not just the field being present.
it("renders the remaining percentage the provider reported", () => {
const rows = parseQuotaData("opencode-go", RAW);

expect(rows.map(getRemainingPercentage)).toEqual([88, 10]);
});
});