diff --git a/open-sse/services/usage.ts b/open-sse/services/usage.ts index 8711e00c89..1f3cade0f6 100644 --- a/open-sse/services/usage.ts +++ b/open-sse/services/usage.ts @@ -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 +): "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): Record { + const ordered: Record = {}; + + 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 = {}; + + 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); diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx index bc653f92f5..bc03e281d9 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx @@ -41,6 +41,7 @@ const PROVIDER_LABEL: Record = { 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 = { 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 = [ diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx index 7620f5302c..4579ecfd22 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx @@ -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; diff --git a/src/lib/usage/providerLimits.ts b/src/lib/usage/providerLimits.ts index 0d13b0a38e..ac12edb201 100644 --- a/src/lib/usage/providerLimits.ts +++ b/src/lib/usage/providerLimits.ts @@ -53,6 +53,7 @@ const PROVIDER_LIMITS_APIKEY_PROVIDERS = new Set([ "glm-cn", "zai", "glmt", + "opencode-go", "minimax", "minimax-cn", "crof", diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index e55ab7a95f..e2a5da13ce 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -2890,6 +2890,7 @@ export const USAGE_SUPPORTED_PROVIDERS = [ "glm-cn", "zai", "glmt", + "opencode-go", "minimax", "minimax-cn", "crof", diff --git a/tests/unit/generic-quota-fetcher.test.ts b/tests/unit/generic-quota-fetcher.test.ts index f8c219fc43..3cfa62d8e1 100644 --- a/tests/unit/generic-quota-fetcher.test.ts +++ b/tests/unit/generic-quota-fetcher.test.ts @@ -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, diff --git a/tests/unit/opencode-go-usage.test.ts b/tests/unit/opencode-go-usage.test.ts new file mode 100644 index 0000000000..63f6fefdd4 --- /dev/null +++ b/tests/unit/opencode-go-usage.test.ts @@ -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; + } +});