mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
fix(opencode-go): add provider limits quota fetcher (#2861)
Integrated into release/v3.8.6
This commit is contained in:
@@ -88,6 +88,16 @@ const NANOGPT_CONFIG = {
|
||||
usageUrl: "https://nano-gpt.com/api/subscription/v1/usage",
|
||||
};
|
||||
|
||||
const OPENCODE_GO_QUOTA_URL =
|
||||
process.env.OMNIROUTE_OPENCODE_GO_QUOTA_URL ?? "https://api.z.ai/api/monitor/usage/quota/limit";
|
||||
const OPENCODE_GO_QUOTA_TOTALS = {
|
||||
session: 12,
|
||||
weekly: 30,
|
||||
mcp_monthly: 60,
|
||||
} as const;
|
||||
const OPENCODE_GO_QUOTA_ORDER = ["session", "weekly", "mcp_monthly"] as const;
|
||||
type OpenCodeGoQuotaName = (typeof OPENCODE_GO_QUOTA_ORDER)[number];
|
||||
|
||||
// Cursor dashboard usage API config
|
||||
// The endpoint that powers https://cursor.com/dashboard/spending. Validates the WorkOS
|
||||
// session via the WorkosCursorSessionToken cookie (format: `${userId}::${jwt}`) and
|
||||
@@ -198,6 +208,76 @@ function getGlmQuotaDisplayName(quotaName: string): string {
|
||||
return quotaName;
|
||||
}
|
||||
|
||||
function getOpenCodeGoTokenQuotaName(
|
||||
limit: JsonRecord,
|
||||
existingQuotas: Record<string, UsageQuota>
|
||||
): "session" | "weekly" {
|
||||
const unit = toNumber(limit.unit, 0);
|
||||
const number = toNumber(limit.number, 0);
|
||||
|
||||
if (unit === 3 && number === 5) return "session";
|
||||
if (unit === 6 && number === 1) return "weekly";
|
||||
if ((unit === 4 && number === 7) || (unit === 3 && number >= 24 * 7)) return "weekly";
|
||||
|
||||
return existingQuotas.session ? "weekly" : "session";
|
||||
}
|
||||
|
||||
function getOpenCodeGoQuotaDisplayName(quotaName: OpenCodeGoQuotaName): string {
|
||||
if (quotaName === "session") return "5-hour rolling";
|
||||
if (quotaName === "weekly") return "Weekly";
|
||||
return "Monthly";
|
||||
}
|
||||
|
||||
function normalizeOpenCodeGoQuotaToken(apiKey: string): string {
|
||||
return apiKey.trim().replace(/^Bearer\s+/i, "");
|
||||
}
|
||||
|
||||
function buildOpenCodeGoDollarQuota(
|
||||
quotaName: OpenCodeGoQuotaName,
|
||||
percentage: unknown,
|
||||
resetAt: string | null,
|
||||
usedOverride?: unknown,
|
||||
details?: UsageQuota["details"]
|
||||
): UsageQuota {
|
||||
const total = OPENCODE_GO_QUOTA_TOTALS[quotaName];
|
||||
const percentUsed = toPercentage(percentage);
|
||||
const rawUsed = toNumber(usedOverride, Number.NaN);
|
||||
const used = roundCurrency(
|
||||
Number.isFinite(rawUsed) ? Math.max(0, Math.min(total, rawUsed)) : (total * percentUsed) / 100
|
||||
);
|
||||
const remaining = roundCurrency(Math.max(0, total - used));
|
||||
const remainingPercentage =
|
||||
total > 0
|
||||
? clampPercentage(Math.round((remaining / total) * 100))
|
||||
: clampPercentage(100 - percentUsed);
|
||||
|
||||
return {
|
||||
used,
|
||||
total,
|
||||
remaining,
|
||||
remainingPercentage,
|
||||
resetAt,
|
||||
unlimited: false,
|
||||
displayName: getOpenCodeGoQuotaDisplayName(quotaName),
|
||||
currency: "USD",
|
||||
details,
|
||||
};
|
||||
}
|
||||
|
||||
function orderOpenCodeGoQuotas(quotas: Record<string, UsageQuota>): Record<string, UsageQuota> {
|
||||
const ordered: Record<string, UsageQuota> = {};
|
||||
|
||||
for (const key of OPENCODE_GO_QUOTA_ORDER) {
|
||||
if (quotas[key]) ordered[key] = quotas[key];
|
||||
}
|
||||
|
||||
for (const [key, quota] of Object.entries(quotas)) {
|
||||
if (!ordered[key]) ordered[key] = quota;
|
||||
}
|
||||
|
||||
return ordered;
|
||||
}
|
||||
|
||||
function getFieldValue(source: unknown, snakeKey: string, camelKey: string): unknown {
|
||||
const obj = toRecord(source);
|
||||
return obj[snakeKey] ?? obj[camelKey] ?? null;
|
||||
@@ -207,6 +287,10 @@ function clampPercentage(value: number): number {
|
||||
return Math.max(0, Math.min(100, value));
|
||||
}
|
||||
|
||||
function roundCurrency(value: number): number {
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
|
||||
function toDisplayLabel(value: string): string {
|
||||
return value
|
||||
.replace(/^copilot[_\s-]*/i, "")
|
||||
@@ -788,6 +872,98 @@ async function getGlmUsage(apiKey: string, providerSpecificData?: Record<string,
|
||||
return { plan, quotas: orderGlmQuotas(quotas) };
|
||||
}
|
||||
|
||||
async function getOpenCodeGoUsage(apiKey: string) {
|
||||
const token = normalizeOpenCodeGoQuotaToken(apiKey);
|
||||
|
||||
if (!token) {
|
||||
return { message: "API key not available. Add an OpenCode Go API key to view usage." };
|
||||
}
|
||||
|
||||
const res = await fetch(OPENCODE_GO_QUOTA_URL, {
|
||||
headers: {
|
||||
Authorization: token,
|
||||
"Accept-Language": "en-US,en",
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 401 || res.status === 403) throw new Error("Invalid OpenCode Go API key");
|
||||
throw new Error(`OpenCode Go quota API error (${res.status})`);
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
const code = toNumber(json.code, 200);
|
||||
if (code === 401 || code === 403 || json.success === false) {
|
||||
throw new Error("Invalid OpenCode Go API key");
|
||||
}
|
||||
|
||||
const data = toRecord(json.data);
|
||||
const limits: unknown[] = Array.isArray(data.limits) ? data.limits : [];
|
||||
const quotas: Record<string, UsageQuota> = {};
|
||||
|
||||
for (const limit of limits) {
|
||||
const src = toRecord(limit);
|
||||
const type = String(src.type || "").toUpperCase();
|
||||
const resetAt = parseResetTime(src.nextResetTime);
|
||||
|
||||
if (type === "TOKENS_LIMIT" || type === "TOKEN_LIMIT") {
|
||||
const quotaName = getOpenCodeGoTokenQuotaName(src, quotas);
|
||||
|
||||
quotas[quotaName] = buildOpenCodeGoDollarQuota(
|
||||
quotaName,
|
||||
src.percentage,
|
||||
resetAt,
|
||||
undefined,
|
||||
Array.isArray(src.models)
|
||||
? (src.models as unknown[]).map((model) => {
|
||||
const modelInfo = toRecord(model);
|
||||
return {
|
||||
name: String(modelInfo.model || modelInfo.modelCode || "usage"),
|
||||
used: toNumber(modelInfo.percentage, 0),
|
||||
};
|
||||
})
|
||||
: undefined
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "TIME_LIMIT" || type === "TIME_USAGE_LIMIT") {
|
||||
quotas.mcp_monthly = buildOpenCodeGoDollarQuota(
|
||||
"mcp_monthly",
|
||||
src.percentage,
|
||||
resetAt,
|
||||
src.currentValue,
|
||||
Array.isArray(src.usageDetails)
|
||||
? src.usageDetails.map((item) => {
|
||||
const detail = toRecord(item);
|
||||
return {
|
||||
name: String(detail.modelCode || detail.name || "usage"),
|
||||
used: toNumber(detail.usage, 0),
|
||||
};
|
||||
})
|
||||
: undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const levelRaw =
|
||||
typeof data.planName === "string"
|
||||
? data.planName
|
||||
: typeof data.level === "string"
|
||||
? data.level
|
||||
: "";
|
||||
const planLabel = toTitleCase(levelRaw.replace(/\s*plan$/i, ""));
|
||||
const plan = planLabel
|
||||
? /^opencode\s+go\b/i.test(planLabel)
|
||||
? planLabel
|
||||
: `OpenCode Go ${planLabel}`
|
||||
: null;
|
||||
|
||||
return { plan, quotas: orderOpenCodeGoQuotas(quotas) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Bailian (Alibaba Coding Plan) Usage
|
||||
* Fetches triple-window quota (5h, weekly, monthly) and returns worst-case.
|
||||
@@ -1184,6 +1360,7 @@ export const USAGE_FETCHER_PROVIDERS = [
|
||||
"glm-cn",
|
||||
"zai",
|
||||
"glmt",
|
||||
"opencode-go",
|
||||
"minimax",
|
||||
"minimax-cn",
|
||||
"crof",
|
||||
@@ -1238,6 +1415,8 @@ export async function getUsageForProvider(
|
||||
...(providerSpecificData || {}),
|
||||
...(provider === "glm-cn" ? { apiRegion: "china" } : {}),
|
||||
});
|
||||
case "opencode-go":
|
||||
return await getOpenCodeGoUsage(apiKey || "");
|
||||
case "minimax":
|
||||
case "minimax-cn":
|
||||
return await getMiniMaxUsage(apiKey || "", provider);
|
||||
|
||||
@@ -41,6 +41,7 @@ const PROVIDER_LABEL: Record<string, string> = {
|
||||
glm: "GLM (Z.AI)",
|
||||
zai: "Z.AI",
|
||||
glmt: "GLM Thinking",
|
||||
"opencode-go": "OpenCode Go",
|
||||
"kimi-coding": "Kimi Coding",
|
||||
minimax: "MiniMax",
|
||||
"minimax-cn": "MiniMax CN",
|
||||
@@ -60,10 +61,11 @@ const PROVIDER_ORDER: Record<string, number> = {
|
||||
glm: 7,
|
||||
zai: 8,
|
||||
glmt: 9,
|
||||
"kimi-coding": 10,
|
||||
minimax: 11,
|
||||
"minimax-cn": 12,
|
||||
nanogpt: 13,
|
||||
"opencode-go": 10,
|
||||
"kimi-coding": 11,
|
||||
minimax: 12,
|
||||
"minimax-cn": 13,
|
||||
nanogpt: 14,
|
||||
};
|
||||
|
||||
const TIER_FILTERS = [
|
||||
|
||||
@@ -246,6 +246,7 @@ export function parseQuotaData(provider, data) {
|
||||
case "glm":
|
||||
case "glm-cn":
|
||||
case "glmt":
|
||||
case "opencode-go":
|
||||
if (data.quotas) {
|
||||
Object.entries(data.quotas).forEach(([name, quota]: [string, any]) => {
|
||||
normalizedQuotas.push(
|
||||
@@ -403,7 +404,12 @@ export function parseQuotaData(provider, data) {
|
||||
});
|
||||
}
|
||||
|
||||
if (providerId === "glm" || providerId === "glm-cn" || providerId === "glmt") {
|
||||
if (
|
||||
providerId === "glm" ||
|
||||
providerId === "glm-cn" ||
|
||||
providerId === "glmt" ||
|
||||
providerId === "opencode-go"
|
||||
) {
|
||||
normalizedQuotas.sort((a, b) => {
|
||||
const orderA = GLM_QUOTA_ORDER[a.name] ?? 99;
|
||||
const orderB = GLM_QUOTA_ORDER[b.name] ?? 99;
|
||||
|
||||
@@ -53,6 +53,7 @@ const PROVIDER_LIMITS_APIKEY_PROVIDERS = new Set([
|
||||
"glm-cn",
|
||||
"zai",
|
||||
"glmt",
|
||||
"opencode-go",
|
||||
"minimax",
|
||||
"minimax-cn",
|
||||
"crof",
|
||||
|
||||
@@ -2890,6 +2890,7 @@ export const USAGE_SUPPORTED_PROVIDERS = [
|
||||
"glm-cn",
|
||||
"zai",
|
||||
"glmt",
|
||||
"opencode-go",
|
||||
"minimax",
|
||||
"minimax-cn",
|
||||
"crof",
|
||||
|
||||
@@ -76,12 +76,13 @@ test("convertUsageToQuotaInfo clamps remainingPercentage outside 0-100", () => {
|
||||
assert.equal(result!.windows!.b.percentUsed, 1);
|
||||
});
|
||||
|
||||
test("registerGenericQuotaFetchers registers Claude and GLM via the generic adapter", () => {
|
||||
test("registerGenericQuotaFetchers registers Claude, GLM, and OpenCode Go via the generic adapter", () => {
|
||||
registerGenericQuotaFetchers();
|
||||
// Claude has no bespoke fetcher → should be registered.
|
||||
assert.ok(getQuotaFetcher("claude"), "claude should be registered");
|
||||
assert.ok(getQuotaFetcher("glm"), "glm should be registered");
|
||||
assert.ok(getQuotaFetcher("zai"), "zai should be registered");
|
||||
assert.ok(getQuotaFetcher("opencode-go"), "opencode-go should be registered");
|
||||
// Codex has its own dedicated fetcher (registered by codexQuotaFetcher.ts,
|
||||
// not by the generic registrar) — the generic registrar skips it. We can't
|
||||
// assert "codex" here without first calling registerCodexQuotaFetcher,
|
||||
|
||||
156
tests/unit/opencode-go-usage.test.ts
Normal file
156
tests/unit/opencode-go-usage.test.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const usage = await import("../../open-sse/services/usage.ts");
|
||||
const { USAGE_SUPPORTED_PROVIDERS } = await import("../../src/shared/constants/providers.ts");
|
||||
|
||||
test("USAGE_SUPPORTED_PROVIDERS includes opencode-go", () => {
|
||||
assert.ok(
|
||||
(USAGE_SUPPORTED_PROVIDERS as string[]).includes("opencode-go"),
|
||||
"opencode-go must be in the usage-supported providers allowlist"
|
||||
);
|
||||
});
|
||||
|
||||
test("getUsageForProvider returns helpful message when opencode-go has no apiKey", async () => {
|
||||
let called = false;
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () => {
|
||||
called = true;
|
||||
return new Response("unexpected", { status: 500 });
|
||||
};
|
||||
|
||||
try {
|
||||
const result = (await usage.getUsageForProvider({
|
||||
id: "opencode-go-no-key",
|
||||
provider: "opencode-go",
|
||||
apiKey: "",
|
||||
})) as { message?: string };
|
||||
|
||||
assert.equal(called, false, "quota fetch must not run without an API key");
|
||||
assert.match(result.message ?? "", /OpenCode Go/);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("getUsageForProvider exposes OpenCode Go 5h, weekly, and monthly quotas", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const reset5h = Date.now() + 2 * 60 * 60 * 1000;
|
||||
const resetWeekly = Date.now() + 4 * 24 * 60 * 60 * 1000;
|
||||
const resetMonthly = Date.now() + 20 * 24 * 60 * 60 * 1000;
|
||||
let requestUrl = "";
|
||||
let requestHeaders: Headers | null = null;
|
||||
|
||||
globalThis.fetch = async (input, init) => {
|
||||
requestUrl = String(input);
|
||||
requestHeaders = new Headers(init?.headers as HeadersInit | undefined);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
code: 200,
|
||||
success: true,
|
||||
data: {
|
||||
level: "pro",
|
||||
limits: [
|
||||
{
|
||||
type: "TOKENS_LIMIT",
|
||||
unit: 3,
|
||||
number: 5,
|
||||
percentage: 25,
|
||||
nextResetTime: reset5h,
|
||||
},
|
||||
{
|
||||
type: "TOKENS_LIMIT",
|
||||
unit: 6,
|
||||
number: 1,
|
||||
percentage: 50,
|
||||
nextResetTime: resetWeekly,
|
||||
},
|
||||
{
|
||||
type: "TIME_LIMIT",
|
||||
percentage: 10,
|
||||
currentValue: 6,
|
||||
usage: 60,
|
||||
nextResetTime: resetMonthly,
|
||||
usageDetails: [{ modelCode: "search-prime", usage: 3 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } }
|
||||
);
|
||||
};
|
||||
|
||||
try {
|
||||
const result = (await usage.getUsageForProvider({
|
||||
id: "opencode-go-usage",
|
||||
provider: "opencode-go",
|
||||
apiKey: "Bearer opencode-go-key",
|
||||
})) as {
|
||||
plan?: string | null;
|
||||
quotas?: Record<
|
||||
string,
|
||||
{
|
||||
used: number;
|
||||
total: number;
|
||||
remaining: number;
|
||||
remainingPercentage: number;
|
||||
resetAt: string | null;
|
||||
displayName?: string;
|
||||
currency?: string;
|
||||
details?: Array<{ name: string; used: number }>;
|
||||
}
|
||||
>;
|
||||
};
|
||||
|
||||
assert.equal(requestUrl, "https://api.z.ai/api/monitor/usage/quota/limit");
|
||||
assert.equal(requestHeaders?.get("Authorization"), "opencode-go-key");
|
||||
assert.equal(requestHeaders?.get("Content-Type"), "application/json");
|
||||
assert.equal(result.plan, "OpenCode Go Pro");
|
||||
assert.deepEqual(Object.keys(result.quotas ?? {}), ["session", "weekly", "mcp_monthly"]);
|
||||
|
||||
assert.equal(result.quotas!.session.displayName, "5-hour rolling");
|
||||
assert.equal(result.quotas!.session.currency, "USD");
|
||||
assert.equal(result.quotas!.session.used, 3);
|
||||
assert.equal(result.quotas!.session.total, 12);
|
||||
assert.equal(result.quotas!.session.remaining, 9);
|
||||
assert.equal(result.quotas!.session.remainingPercentage, 75);
|
||||
assert.equal(result.quotas!.session.resetAt, new Date(reset5h).toISOString());
|
||||
|
||||
assert.equal(result.quotas!.weekly.displayName, "Weekly");
|
||||
assert.equal(result.quotas!.weekly.used, 15);
|
||||
assert.equal(result.quotas!.weekly.total, 30);
|
||||
assert.equal(result.quotas!.weekly.remaining, 15);
|
||||
assert.equal(result.quotas!.weekly.remainingPercentage, 50);
|
||||
assert.equal(result.quotas!.weekly.resetAt, new Date(resetWeekly).toISOString());
|
||||
|
||||
assert.equal(result.quotas!.mcp_monthly.displayName, "Monthly");
|
||||
assert.equal(result.quotas!.mcp_monthly.used, 6);
|
||||
assert.equal(result.quotas!.mcp_monthly.total, 60);
|
||||
assert.equal(result.quotas!.mcp_monthly.remaining, 54);
|
||||
assert.equal(result.quotas!.mcp_monthly.remainingPercentage, 90);
|
||||
assert.equal(result.quotas!.mcp_monthly.resetAt, new Date(resetMonthly).toISOString());
|
||||
assert.deepEqual(result.quotas!.mcp_monthly.details, [{ name: "search-prime", used: 3 }]);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("getUsageForProvider rejects invalid OpenCode Go API keys", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () => new Response("nope", { status: 401 });
|
||||
|
||||
try {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
usage.getUsageForProvider({
|
||||
id: "opencode-go-401",
|
||||
provider: "opencode-go",
|
||||
apiKey: "bad-key",
|
||||
}),
|
||||
/Invalid OpenCode Go API key/
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user