From d3781abae5533a5654add94479e5e4e8ca1e12f5 Mon Sep 17 00:00:00 2001 From: Xiangfang Chen <565499699@qq.com> Date: Wed, 25 Mar 2026 19:55:47 +0800 Subject: [PATCH 1/2] feat(opencode): add quota command to query provider quota --- packages/opencode/src/cli/cmd/quota.ts | 1349 ++++++++++++++++++++++++ packages/opencode/src/index.ts | 2 + 2 files changed, 1351 insertions(+) create mode 100644 packages/opencode/src/cli/cmd/quota.ts diff --git a/packages/opencode/src/cli/cmd/quota.ts b/packages/opencode/src/cli/cmd/quota.ts new file mode 100644 index 000000000000..6b0150ca04b4 --- /dev/null +++ b/packages/opencode/src/cli/cmd/quota.ts @@ -0,0 +1,1349 @@ +import path from "path" +import os from "os" +import type { Argv } from "yargs" +import { cmd } from "./cmd" +import { Auth } from "../../auth" +import { UI } from "../ui" + +interface UsageWindow { + usedPercent: number | null + remainingPercent: number | null + windowSeconds: number | null + resetAfterSeconds: number | null + resetAt: number | null + resetAtFormatted: string | null + resetAfterFormatted: string | null + valueLabel?: string | null +} + +interface ProviderUsage { + windows: Record + models?: Record +} + +interface QuotaProviderResult { + providerId: string + providerName: string + ok: boolean + configured: boolean + usage: ProviderUsage | null + error: string | null + fetchedAt: number +} + +// Antigravity account storage +interface AntigravityAccount { + email?: string + refreshToken: string + projectId?: string + managedProjectId?: string + addedAt: number + lastUsed: number + enabled?: boolean +} + +interface AntigravityStorage { + version: number + accounts: AntigravityAccount[] + activeIndex: number +} + +const getAntigravityStoragePath = (): string => { + const xdgConfig = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config") + return path.join(xdgConfig, "opencode", "antigravity-accounts.json") +} + +const loadAntigravityAccounts = async (): Promise => { + try { + const filePath = getAntigravityStoragePath() + const content = await Bun.file(filePath).text() + const storage: AntigravityStorage = JSON.parse(content) + if (storage.version >= 3 && Array.isArray(storage.accounts)) { + return storage.accounts.filter((a) => a.enabled !== false && a.refreshToken) + } + return [] + } catch { + return [] + } +} + +// Utility functions (from openchamber's utils) +const toNumber = (value: unknown): number | null => { + if (typeof value === "number" && Number.isFinite(value)) return value + if (typeof value === "string") { + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : null + } + return null +} + +const toTimestamp = (value: unknown): number | null => { + if (!value) return null + if (typeof value === "number") return value < 1_000_000_000_000 ? value * 1000 : value + if (typeof value === "string") { + const parsed = Date.parse(value) + return Number.isNaN(parsed) ? null : parsed + } + return null +} + +// Helper to create a UsageWindow with usedPercent -> remaining calculation +const toQuotaWindow = (usedPercent: number | null, resetAt: number | null): UsageWindow => ({ + usedPercent, + remainingPercent: usedPercent !== null ? 100 - usedPercent : null, + windowSeconds: null, + resetAfterSeconds: resetAt !== null ? Math.max(0, resetAt - Date.now()) / 1000 : null, + resetAt, + resetAtFormatted: resetAt !== null ? new Date(resetAt).toLocaleString() : null, + resetAfterFormatted: null, + valueLabel: null, +}) + +// Parse refresh token (format: token|projectId|managedProjectId) +const parseRefreshToken = (raw: string) => { + if (!raw) return { refreshToken: null, projectId: null } + const parts = raw.split("|") + return { refreshToken: parts[0] || null, projectId: parts[1] || null } +} + +const buildResult = (input: { + providerId: string + providerName: string + ok: boolean + configured: boolean + usage?: ProviderUsage + error?: string +}): QuotaProviderResult => ({ + ...input, + usage: input.usage ?? null, + error: input.error ?? null, + fetchedAt: Date.now(), +}) + +const toUsageWindow = (input: { + usedPercent: number | null + windowSeconds: number | null + resetAt: number | null + valueLabel?: string | null +}): UsageWindow => { + const used = toNumber(input.usedPercent) + const remaining = used !== null ? 100 - used : null + + const resetAtFormatted = input.resetAt !== null ? new Date(input.resetAt).toLocaleString() : null + + return { + usedPercent: used, + remainingPercent: remaining, + windowSeconds: toNumber(input.windowSeconds), + resetAfterSeconds: input.resetAt !== null ? Math.max(0, input.resetAt - Date.now()) / 1000 : null, + resetAt: input.resetAt, + resetAtFormatted, + resetAfterFormatted: null, + valueLabel: input.valueLabel, + } +} + +// Provider implementations +interface QuotaProviderModule { + providerId: string + providerName: string + aliases: string[] + isConfigured: (auth: Auth.Info | undefined) => boolean | Promise + fetchQuota: (auth: Auth.Info | undefined) => Promise +} + +const claudeProvider: QuotaProviderModule = { + providerId: "claude", + providerName: "Claude", + aliases: ["anthropic", "claude"], + isConfigured: (auth) => Boolean(auth?.type === "oauth" || auth?.type === "api"), + fetchQuota: async (auth) => { + if (!auth) { + return buildResult({ + providerId: "claude", + providerName: "Claude", + ok: false, + configured: false, + error: "Not configured", + }) + } + + const accessToken = auth.type === "oauth" ? auth.access : auth.type === "api" ? auth.key : null + if (!accessToken) { + return buildResult({ + providerId: "claude", + providerName: "Claude", + ok: false, + configured: false, + error: "No access token", + }) + } + + try { + const response = await fetch("https://api.anthropic.com/api/oauth/usage", { + method: "GET", + headers: { + Authorization: `Bearer ${accessToken}`, + "anthropic-beta": "oauth-2025-04-20", + }, + }) + + if (!response.ok) { + return buildResult({ + providerId: "claude", + providerName: "Claude", + ok: false, + configured: true, + error: `API error: ${response.status}`, + }) + } + + const payload = await response.json() + const windows: Record = {} + + const fiveHour = payload?.five_hour + const sevenDay = payload?.seven_day + const sevenDaySonnet = payload?.seven_day_sonnet + const sevenDayOpus = payload?.seven_day_opus + + if (fiveHour) { + windows["5h"] = toUsageWindow({ + usedPercent: toNumber(fiveHour.utilization), + windowSeconds: null, + resetAt: toTimestamp(fiveHour.resets_at), + }) + } + if (sevenDay) { + windows["7d"] = toUsageWindow({ + usedPercent: toNumber(sevenDay.utilization), + windowSeconds: null, + resetAt: toTimestamp(sevenDay.resets_at), + }) + } + if (sevenDaySonnet) { + windows["7d-sonnet"] = toUsageWindow({ + usedPercent: toNumber(sevenDaySonnet.utilization), + windowSeconds: null, + resetAt: toTimestamp(sevenDaySonnet.resets_at), + }) + } + if (sevenDayOpus) { + windows["7d-opus"] = toUsageWindow({ + usedPercent: toNumber(sevenDayOpus.utilization), + windowSeconds: null, + resetAt: toTimestamp(sevenDayOpus.resets_at), + }) + } + + return buildResult({ + providerId: "claude", + providerName: "Claude", + ok: true, + configured: true, + usage: { windows }, + }) + } catch (e) { + return buildResult({ + providerId: "claude", + providerName: "Claude", + ok: false, + configured: true, + error: e instanceof Error ? e.message : "Request failed", + }) + } + }, +} + +const codexProvider: QuotaProviderModule = { + providerId: "codex", + providerName: "Codex", + aliases: ["openai", "codex", "chatgpt"], + isConfigured: (auth) => Boolean(auth?.type === "oauth" || auth?.type === "api"), + fetchQuota: async (auth) => { + if (!auth) { + return buildResult({ + providerId: "codex", + providerName: "Codex", + ok: false, + configured: false, + error: "Not configured", + }) + } + + const accessToken = auth.type === "oauth" ? auth.access : auth.type === "api" ? auth.key : null + const accountId = auth.type === "oauth" ? auth.accountId : null + if (!accessToken) { + return buildResult({ + providerId: "codex", + providerName: "Codex", + ok: false, + configured: false, + error: "No access token", + }) + } + + try { + const headers: Record = { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + } + if (accountId) headers["ChatGPT-Account-Id"] = accountId + + const response = await fetch("https://chatgpt.com/backend-api/wham/usage", { method: "GET", headers }) + + if (!response.ok) { + const errorMsg = + response.status === 401 + ? "Session expired — please re-authenticate with OpenAI" + : `API error: ${response.status}` + return buildResult({ providerId: "codex", providerName: "Codex", ok: false, configured: true, error: errorMsg }) + } + + const payload = await response.json() + const windows: Record = {} + + const primary = payload?.rate_limit?.primary_window + const secondary = payload?.rate_limit?.secondary_window + const credits = payload?.credits + + if (primary) { + windows["5h"] = toUsageWindow({ + usedPercent: toNumber(primary.used_percent), + windowSeconds: toNumber(primary.limit_window_seconds), + resetAt: toTimestamp(primary.reset_at), + }) + } + if (secondary) { + windows["weekly"] = toUsageWindow({ + usedPercent: toNumber(secondary.used_percent), + windowSeconds: toNumber(secondary.limit_window_seconds), + resetAt: toTimestamp(secondary.reset_at), + }) + } + if (credits) { + const balance = toNumber(credits.balance) + const unlimited = Boolean(credits.unlimited) + const label = unlimited ? "Unlimited" : balance !== null ? `$${balance.toFixed(2)} remaining` : null + windows["credits"] = toUsageWindow({ usedPercent: null, windowSeconds: null, resetAt: null, valueLabel: label }) + } + + return buildResult({ providerId: "codex", providerName: "Codex", ok: true, configured: true, usage: { windows } }) + } catch (e) { + return buildResult({ + providerId: "codex", + providerName: "Codex", + ok: false, + configured: true, + error: e instanceof Error ? e.message : "Request failed", + }) + } + }, +} + +// Google OAuth credentials for installed application +// It's ok to save this in git because this is an installed application +// as described here: https://developers.google.com/identity/protocols/oauth2#installed +// "The process results in a client ID and, in some cases, a client secret, +// which you embed in the source code of your application. (In this context, +// the client secret is obviously not treated as a secret.)" +const GOOGLE_CLIENT_ID = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com" +const GOOGLE_CLIENT_SECRET = "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" + +const refreshGoogleAccessToken = async ( + refreshToken: string, + clientId: string = GOOGLE_CLIENT_ID, + clientSecret: string = GOOGLE_CLIENT_SECRET, +): Promise => { + try { + const response = await fetch("https://oauth2.googleapis.com/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + refresh_token: refreshToken, + grant_type: "refresh_token", + }), + }) + + if (!response.ok) return null + const data = await response.json() + return typeof data?.access_token === "string" ? data.access_token : null + } catch { + return null + } +} + +interface GoogleModelEntry { + quotaInfo?: { + remainingFraction?: number + resetTime?: string + } + displayName?: string + modelName?: string +} + +interface GoogleQuotaResponse { + models?: Record +} + +interface GeminiCliBucket { + modelId?: string + remainingFraction?: number + resetTime?: string +} + +interface GeminiCliQuotaResponse { + buckets?: GeminiCliBucket[] +} + +const fetchGoogleQuota = async (accessToken: string): Promise => { + const endpoints = [ + "https://daily-cloudcode-pa.sandbox.googleapis.com", + "https://autopush-cloudcode-pa.sandbox.googleapis.com", + "https://cloudcode-pa.googleapis.com", + ] + + for (const endpoint of endpoints) { + try { + const response = await fetch(`${endpoint}/v1internal:fetchAvailableModels`, { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + "User-Agent": "antigravity/1.11.5 windows/amd64", + "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1", + "Client-Metadata": '{"ideType":"IDE_UNSPECIFIED","platform":"PLATFORM_UNSPECIFIED","pluginType":"GEMINI"}', + }, + body: JSON.stringify({}), + signal: AbortSignal.timeout(15000), + }) + + if (response.ok) { + return await response.json() + } + } catch { + continue + } + } + return null +} + +const fetchGeminiCliQuota = async (accessToken: string): Promise => { + const endpoints = [ + "https://daily-cloudcode-pa.sandbox.googleapis.com", + "https://autopush-cloudcode-pa.sandbox.googleapis.com", + "https://cloudcode-pa.googleapis.com", + ] + + // Use Gemini CLI user-agent to get CLI quota buckets + const platform = process.platform || "darwin" + const arch = process.arch || "arm64" + const geminiCliUserAgent = `GeminiCLI/1.0.0/gemini-2.5-pro (${platform}; ${arch})` + + for (const endpoint of endpoints) { + try { + const response = await fetch(`${endpoint}/v1internal:retrieveUserQuota`, { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + "User-Agent": geminiCliUserAgent, + }, + body: JSON.stringify({}), + signal: AbortSignal.timeout(15000), + }) + + if (response.ok) { + return await response.json() + } + } catch { + continue + } + } + return null +} + +const googleProvider: QuotaProviderModule = { + providerId: "google", + providerName: "Google", + aliases: ["google", "gemini"], + isConfigured: async () => { + // Check Antigravity accounts first + const accounts = await loadAntigravityAccounts() + if (accounts.length > 0) return true + // Fall back to built-in auth + const authMap = await Auth.all() + return Boolean( + authMap.google?.type === "oauth" || authMap.google?.type === "api" || authMap.google?.type === "wellknown", + ) + }, + fetchQuota: async (auth) => { + // Try Antigravity accounts first + const accounts = await loadAntigravityAccounts() + if (accounts.length > 0) { + // Use the active account + const account = accounts[0] + const parsed = parseRefreshToken(account.refreshToken) + let accessToken: string | null = null + + // Try to refresh token + if (parsed.refreshToken) { + accessToken = await refreshGoogleAccessToken(parsed.refreshToken) + } + + if (!accessToken) { + return buildResult({ + providerId: "google", + providerName: "Google", + ok: false, + configured: true, + error: `Failed to refresh token for ${account.email || "Antigravity account"}`, + }) + } + + try { + // Fetch both Antigravity and Gemini CLI quotas in parallel + const [payload, geminiCliPayload] = await Promise.all([ + fetchGoogleQuota(accessToken), + fetchGeminiCliQuota(accessToken), + ]) + + if (!payload) { + return buildResult({ + providerId: "google", + providerName: "Google", + ok: false, + configured: true, + error: "Failed to fetch models", + }) + } + + const windows: Record = {} + + // Parse Antigravity model quotas + const antigravityPatterns = ["gemini-3", "claude-", "gpt-"] + for (const [modelName, modelData] of Object.entries(payload?.models ?? {})) { + if (!antigravityPatterns.some((p) => modelName.includes(p))) continue + const quotaInfo = modelData.quotaInfo + if (!quotaInfo || typeof quotaInfo.remainingFraction !== "number") continue + const name = modelName.startsWith("antigravity/") ? modelName.replace("antigravity/", "") : modelName + windows[`(Antigravity) ${name}`] = toQuotaWindow( + Math.round((1 - quotaInfo.remainingFraction) * 100), + toTimestamp(quotaInfo.resetTime), + ) + } + + // Parse Gemini CLI quota buckets + for (const bucket of geminiCliPayload?.buckets ?? []) { + const isRelevant = bucket.modelId?.startsWith("gemini-3-") || bucket.modelId === "gemini-2.5-pro" + if (!isRelevant || typeof bucket.remainingFraction !== "number") continue + windows[`(Gemini CLI) ${bucket.modelId}`] = toQuotaWindow( + Math.round((1 - bucket.remainingFraction) * 100), + toTimestamp(bucket.resetTime), + ) + } + + return buildResult({ + providerId: "google", + providerName: "Google", + ok: true, + configured: true, + usage: { windows: Object.keys(windows).length ? windows : { quota: toQuotaWindow(null, null) } }, + }) + } catch (e) { + return buildResult({ + providerId: "google", + providerName: "Google", + ok: false, + configured: true, + error: e instanceof Error ? e.message : "Request failed", + }) + } + } + + // Fall back to built-in auth + if (!auth) + return buildResult({ + providerId: "google", + providerName: "Google", + ok: false, + configured: false, + error: "Not configured", + }) + + let accessToken: string | null = null + if (auth.type === "oauth") { + const parsed = parseRefreshToken(auth.refresh) + accessToken = auth.access || (parsed.refreshToken ? await refreshGoogleAccessToken(parsed.refreshToken) : null) + } else if (auth.type === "api") { + accessToken = auth.key + } else if (auth.type === "wellknown") { + accessToken = auth.token + } + + if (!accessToken) + return buildResult({ + providerId: "google", + providerName: "Google", + ok: false, + configured: false, + error: "No access token", + }) + + try { + const payload = await fetchGoogleQuota(accessToken) + if (!payload) + return buildResult({ + providerId: "google", + providerName: "Google", + ok: false, + configured: true, + error: "Failed to fetch models", + }) + + const windows: Record = payload?.models ? { quota: toQuotaWindow(null, null) } : {} + return buildResult({ + providerId: "google", + providerName: "Google", + ok: true, + configured: true, + usage: { windows }, + }) + } catch (e) { + return buildResult({ + providerId: "google", + providerName: "Google", + ok: false, + configured: true, + error: e instanceof Error ? e.message : "Request failed", + }) + } + }, +} + +const zaiProvider: QuotaProviderModule = { + providerId: "zai-coding-plan", + providerName: "Z.AI Coding Plan", + aliases: ["zai-coding-plan", "zai", "z.ai"], + isConfigured: (auth) => Boolean(auth?.type === "api" || auth?.type === "wellknown"), + fetchQuota: async (auth) => { + if (!auth) { + return buildResult({ + providerId: "zai-coding-plan", + providerName: "Z.AI Coding Plan", + ok: false, + configured: false, + error: "Not configured", + }) + } + + const apiKey = auth.type === "api" ? auth.key : auth.type === "wellknown" ? auth.token : null + if (!apiKey) { + return buildResult({ + providerId: "zai-coding-plan", + providerName: "Z.AI Coding Plan", + ok: false, + configured: false, + error: "No API key", + }) + } + + try { + const response = await fetch("https://api.z.ai/api/monitor/usage/quota/limit", { + method: "GET", + headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" }, + }) + + if (!response.ok) { + return buildResult({ + providerId: "zai-coding-plan", + providerName: "Z.AI Coding Plan", + ok: false, + configured: true, + error: `API error: ${response.status}`, + }) + } + + const payload = await response.json() + const limits = Array.isArray(payload?.data?.limits) ? payload.data.limits : [] + const tokensLimit = limits.find((l: Record) => l?.type === "TOKENS_LIMIT") + + const resolveWindowSeconds = (limit: Record | undefined) => { + const unitSeconds: Record = { 3: 3600 } + if (!limit || typeof limit.number !== "number") return null + const us = unitSeconds[limit.unit as number] + return us ? us * (limit.number as number) : null + } + + const resolveWindowLabel = (seconds: number | null) => { + if (!seconds) return "tokens" + if (seconds % 86400 === 0) return seconds / 86400 === 7 ? "weekly" : `${seconds / 86400}d` + if (seconds % 3600 === 0) return `${seconds / 3600}h` + return `${seconds}s` + } + + const windowSeconds = resolveWindowSeconds(tokensLimit) + const windowLabel = resolveWindowLabel(windowSeconds) + const resetAt = tokensLimit?.nextResetTime ? toTimestamp(tokensLimit.nextResetTime) : null + const usedPercent = typeof tokensLimit?.percentage === "number" ? tokensLimit.percentage : null + + const windows: Record = {} + if (tokensLimit) { + windows[windowLabel] = toUsageWindow({ usedPercent, windowSeconds, resetAt }) + } + + return buildResult({ + providerId: "zai-coding-plan", + providerName: "Z.AI Coding Plan", + ok: true, + configured: true, + usage: { windows }, + }) + } catch (e) { + return buildResult({ + providerId: "zai-coding-plan", + providerName: "Z.AI Coding Plan", + ok: false, + configured: true, + error: e instanceof Error ? e.message : "Request failed", + }) + } + }, +} + +const zhipuaiProvider: QuotaProviderModule = { + providerId: "zhipuai-coding-plan", + providerName: "ZhipuAI Coding Plan", + aliases: ["zhipuai-coding-plan", "zhipuai"], + isConfigured: (auth) => Boolean(auth?.type === "api"), + fetchQuota: async (auth) => { + if (!auth || auth.type !== "api") { + return buildResult({ + providerId: "zhipuai-coding-plan", + providerName: "ZhipuAI Coding Plan", + ok: false, + configured: false, + error: "Not configured", + }) + } + + const apiKey = auth.key + if (!apiKey) { + return buildResult({ + providerId: "zhipuai-coding-plan", + providerName: "ZhipuAI Coding Plan", + ok: false, + configured: false, + error: "No API key", + }) + } + + try { + const response = await fetch("https://open.bigmodel.cn/api/monitor/usage/quota/limit", { + method: "GET", + headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" }, + }) + + if (!response.ok) { + return buildResult({ + providerId: "zhipuai-coding-plan", + providerName: "ZhipuAI Coding Plan", + ok: false, + configured: true, + error: `API error: ${response.status}`, + }) + } + + const payload = await response.json() + const limits = Array.isArray(payload?.data?.limits) ? payload.data.limits : [] + const windows: Record = {} + + for (const limit of limits) { + if (!limit || typeof limit !== "object") continue + const l = limit as Record + if (l?.type === "TOKENS_LIMIT") { + const ws = toNumber(l.number) + if (ws !== null) { + windows["Tokens"] = toUsageWindow({ + usedPercent: toNumber(l.percentage), + windowSeconds: ws * 3600, + resetAt: toTimestamp(l.nextResetTime), + }) + } + } else if (l?.type === "TIME_LIMIT") { + const ws = toNumber(l.number) + if (ws !== null) { + windows["MCP Tools"] = toUsageWindow({ + usedPercent: toNumber(l.percentage), + windowSeconds: ws * 60, + resetAt: toTimestamp(l.nextResetTime), + }) + } + } + } + + return buildResult({ + providerId: "zhipuai-coding-plan", + providerName: "ZhipuAI Coding Plan", + ok: true, + configured: true, + usage: { windows }, + }) + } catch (e) { + return buildResult({ + providerId: "zhipuai-coding-plan", + providerName: "ZhipuAI Coding Plan", + ok: false, + configured: true, + error: e instanceof Error ? e.message : "Request failed", + }) + } + }, +} + +const kimiProvider: QuotaProviderModule = { + providerId: "kimi-for-coding", + providerName: "Kimi Coding Plan", + aliases: ["kimi-for-coding", "kimi"], + isConfigured: (auth) => Boolean(auth?.type === "api"), + fetchQuota: async (auth) => { + if (!auth || auth.type !== "api") { + return buildResult({ + providerId: "kimi-for-coding", + providerName: "Kimi Coding Plan", + ok: false, + configured: false, + error: "Not configured", + }) + } + + const apiKey = auth.key + if (!apiKey) { + return buildResult({ + providerId: "kimi-for-coding", + providerName: "Kimi Coding Plan", + ok: false, + configured: false, + error: "No API key", + }) + } + + try { + const response = await fetch("https://platform.moonshot.cn/api/v1/usage/quota", { + method: "GET", + headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" }, + }) + + if (!response.ok) { + return buildResult({ + providerId: "kimi-for-coding", + providerName: "Kimi Coding Plan", + ok: false, + configured: true, + error: `API error: ${response.status}`, + }) + } + + const payload = await response.json() + const windows: Record = {} + + if (payload?.data) { + const used = toNumber(payload.data.used) + const limit = toNumber(payload.data.limit) + const usedPercent = used !== null && limit !== null && limit > 0 ? (used / limit) * 100 : null + windows["quota"] = toUsageWindow({ + usedPercent, + windowSeconds: null, + resetAt: toTimestamp(payload.data.reset_at), + }) + } + + return buildResult({ + providerId: "kimi-for-coding", + providerName: "Kimi Coding Plan", + ok: true, + configured: true, + usage: { windows }, + }) + } catch (e) { + return buildResult({ + providerId: "kimi-for-coding", + providerName: "Kimi Coding Plan", + ok: false, + configured: true, + error: e instanceof Error ? e.message : "Request failed", + }) + } + }, +} + +const openrouterProvider: QuotaProviderModule = { + providerId: "openrouter", + providerName: "OpenRouter", + aliases: ["openrouter"], + isConfigured: (auth) => Boolean(auth?.type === "api"), + fetchQuota: async (auth) => { + if (!auth || auth.type !== "api") { + return buildResult({ + providerId: "openrouter", + providerName: "OpenRouter", + ok: false, + configured: false, + error: "Not configured", + }) + } + + const apiKey = auth.key + if (!apiKey) { + return buildResult({ + providerId: "openrouter", + providerName: "OpenRouter", + ok: false, + configured: false, + error: "No API key", + }) + } + + try { + const response = await fetch("https://openrouter.ai/api/v1/quota", { + method: "GET", + headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" }, + }) + + if (!response.ok) { + return buildResult({ + providerId: "openrouter", + providerName: "OpenRouter", + ok: false, + configured: true, + error: `API error: ${response.status}`, + }) + } + + const payload = await response.json() + const windows: Record = {} + + if (payload?.data) { + const data = payload.data + const used = toNumber(data.total_usage) + const limit = toNumber(data.limit) + const usedPercent = used !== null && limit !== null && limit > 0 ? (used / limit) * 100 : null + windows["quota"] = toUsageWindow({ usedPercent, windowSeconds: null, resetAt: toTimestamp(data.reset_at) }) + } + + return buildResult({ + providerId: "openrouter", + providerName: "OpenRouter", + ok: true, + configured: true, + usage: { windows }, + }) + } catch (e) { + return buildResult({ + providerId: "openrouter", + providerName: "OpenRouter", + ok: false, + configured: true, + error: e instanceof Error ? e.message : "Request failed", + }) + } + }, +} + +const nanogptProvider: QuotaProviderModule = { + providerId: "nano-gpt", + providerName: "NanoGPT", + aliases: ["nano-gpt", "nanogpt", "nano_gpt"], + isConfigured: (auth) => Boolean(auth?.type === "api"), + fetchQuota: async (auth) => { + if (!auth || auth.type !== "api") { + return buildResult({ + providerId: "nano-gpt", + providerName: "NanoGPT", + ok: false, + configured: false, + error: "Not configured", + }) + } + + const apiKey = auth.key + if (!apiKey) { + return buildResult({ + providerId: "nano-gpt", + providerName: "NanoGPT", + ok: false, + configured: false, + error: "No API key", + }) + } + + try { + const response = await fetch("https://api.nanogpt.io/v1/quota", { + method: "GET", + headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" }, + }) + + if (!response.ok) { + return buildResult({ + providerId: "nano-gpt", + providerName: "NanoGPT", + ok: false, + configured: true, + error: `API error: ${response.status}`, + }) + } + + const payload = await response.json() + const windows: Record = {} + + if (payload?.data) { + windows["quota"] = toUsageWindow({ + usedPercent: toNumber(payload.data.percentage), + windowSeconds: null, + resetAt: toTimestamp(payload.data.reset_at), + }) + } + + return buildResult({ + providerId: "nano-gpt", + providerName: "NanoGPT", + ok: true, + configured: true, + usage: { windows }, + }) + } catch (e) { + return buildResult({ + providerId: "nano-gpt", + providerName: "NanoGPT", + ok: false, + configured: true, + error: e instanceof Error ? e.message : "Request failed", + }) + } + }, +} + +const copilotProvider: QuotaProviderModule = { + providerId: "github-copilot", + providerName: "GitHub Copilot", + aliases: ["github-copilot", "copilot"], + isConfigured: (auth) => Boolean(auth?.type === "oauth" || auth?.type === "api"), + fetchQuota: async (auth) => { + if (!auth) { + return buildResult({ + providerId: "github-copilot", + providerName: "GitHub Copilot", + ok: false, + configured: false, + error: "Not configured", + }) + } + + const accessToken = auth.type === "oauth" ? auth.access : auth.type === "api" ? auth.key : null + if (!accessToken) { + return buildResult({ + providerId: "github-copilot", + providerName: "GitHub Copilot", + ok: false, + configured: false, + error: "No access token", + }) + } + + try { + const response = await fetch("https://api.github.com/copilot/usage", { + method: "GET", + headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/vnd.github.copilot-usage+json" }, + }) + + if (!response.ok) { + return buildResult({ + providerId: "github-copilot", + providerName: "GitHub Copilot", + ok: false, + configured: true, + error: `API error: ${response.status}`, + }) + } + + const payload = await response.json() + const windows: Record = {} + + if (payload?.capabilities) { + const caps = payload.capabilities + const used = + caps.used_seats && caps.total_seats && caps.total_seats > 0 + ? (caps.used_seats / caps.total_seats) * 100 + : null + windows["seats"] = toUsageWindow({ usedPercent: used, windowSeconds: null, resetAt: null }) + } + + return buildResult({ + providerId: "github-copilot", + providerName: "GitHub Copilot", + ok: true, + configured: true, + usage: { windows }, + }) + } catch (e) { + return buildResult({ + providerId: "github-copilot", + providerName: "GitHub Copilot", + ok: false, + configured: true, + error: e instanceof Error ? e.message : "Request failed", + }) + } + }, +} + +const minimaxProvider: QuotaProviderModule = { + providerId: "minimax-coding-plan", + providerName: "MiniMax Coding Plan", + aliases: ["minimax-coding-plan", "minimax"], + isConfigured: (auth) => Boolean(auth?.type === "api"), + fetchQuota: async (auth) => { + if (!auth || auth.type !== "api") { + return buildResult({ + providerId: "minimax-coding-plan", + providerName: "MiniMax Coding Plan", + ok: false, + configured: false, + error: "Not configured", + }) + } + + const apiKey = auth.key + if (!apiKey) { + return buildResult({ + providerId: "minimax-coding-plan", + providerName: "MiniMax Coding Plan", + ok: false, + configured: false, + error: "No API key", + }) + } + + try { + const response = await fetch("https://platform.minimax.io/api/v1/usage/quota", { + method: "GET", + headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" }, + }) + + if (!response.ok) { + return buildResult({ + providerId: "minimax-coding-plan", + providerName: "MiniMax Coding Plan", + ok: false, + configured: true, + error: `API error: ${response.status}`, + }) + } + + const payload = await response.json() + const windows: Record = {} + + if (payload?.data) { + windows["quota"] = toUsageWindow({ + usedPercent: toNumber(payload.data.usage_percentage), + windowSeconds: null, + resetAt: toTimestamp(payload.data.reset_at), + }) + } + + return buildResult({ + providerId: "minimax-coding-plan", + providerName: "MiniMax Coding Plan", + ok: true, + configured: true, + usage: { windows }, + }) + } catch (e) { + return buildResult({ + providerId: "minimax-coding-plan", + providerName: "MiniMax Coding Plan", + ok: false, + configured: true, + error: e instanceof Error ? e.message : "Request failed", + }) + } + }, +} + +const ollamaCloudProvider: QuotaProviderModule = { + providerId: "ollama-cloud", + providerName: "Ollama Cloud", + aliases: ["ollama-cloud"], + isConfigured: (auth) => Boolean(auth?.type === "api"), + fetchQuota: async (auth) => { + if (!auth || auth.type !== "api") { + return buildResult({ + providerId: "ollama-cloud", + providerName: "Ollama Cloud", + ok: false, + configured: false, + error: "Not configured", + }) + } + + const apiKey = auth.key + if (!apiKey) { + return buildResult({ + providerId: "ollama-cloud", + providerName: "Ollama Cloud", + ok: false, + configured: false, + error: "No API key", + }) + } + + try { + const response = await fetch("https://cloud.ollama.ai/api/quota", { + method: "GET", + headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" }, + }) + + if (!response.ok) { + return buildResult({ + providerId: "ollama-cloud", + providerName: "Ollama Cloud", + ok: false, + configured: true, + error: `API error: ${response.status}`, + }) + } + + const payload = await response.json() + const windows: Record = {} + + if (payload?.quota) { + const quota = payload.quota + const used = toNumber(quota.used) + const limit = toNumber(quota.limit) + const usedPercent = used !== null && limit !== null && limit > 0 ? (used / limit) * 100 : null + windows["quota"] = toUsageWindow({ usedPercent, windowSeconds: null, resetAt: toTimestamp(quota.reset_at) }) + } + + return buildResult({ + providerId: "ollama-cloud", + providerName: "Ollama Cloud", + ok: true, + configured: true, + usage: { windows }, + }) + } catch (e) { + return buildResult({ + providerId: "ollama-cloud", + providerName: "Ollama Cloud", + ok: false, + configured: true, + error: e instanceof Error ? e.message : "Request failed", + }) + } + }, +} + +// Registry +const PROVIDERS: Record = { + claude: claudeProvider, + codex: codexProvider, + google: googleProvider, + "zai-coding-plan": zaiProvider, + "zhipuai-coding-plan": zhipuaiProvider, + "kimi-for-coding": kimiProvider, + openrouter: openrouterProvider, + "nano-gpt": nanogptProvider, + "github-copilot": copilotProvider, + "minimax-coding-plan": minimaxProvider, + "ollama-cloud": ollamaCloudProvider, +} + +function findAuthForAliases(authMap: Record, aliases: string[]): Auth.Info | undefined { + for (const alias of aliases) { + if (authMap[alias]) return authMap[alias] + } + return undefined +} + +export const QuotaCommand = cmd({ + command: "quota [provider]", + describe: "show quota usage for providers", + builder: (yargs: Argv) => { + return yargs.positional("provider", { describe: "provider ID to check quota for", type: "string" }) + }, + handler: async (args) => { + const authMap = await Auth.all() + + if (args.provider) { + const provider = PROVIDERS[args.provider] + if (!provider) { + UI.error(`Unknown provider: ${args.provider}`) + UI.println(`Available providers: ${Object.keys(PROVIDERS).join(", ")}`) + return + } + + const auth = findAuthForAliases(authMap, provider.aliases) + const isConfigured = await provider.isConfigured(auth) + if (!auth || !isConfigured) { + UI.error(`Provider ${args.provider} is not configured. Run 'opencode auth login ${args.provider}' first.`) + return + } + + UI.println(`Checking quota for ${provider.providerName}...`) + const result = await provider.fetchQuota(auth) + displayResult(result) + } else { + const configured: string[] = [] + for (const [id, provider] of Object.entries(PROVIDERS)) { + const auth = findAuthForAliases(authMap, provider.aliases) + const isConfigured = await provider.isConfigured(auth) + if (isConfigured) configured.push(id) + } + + if (configured.length === 0) { + UI.println("No providers configured with quota support.") + return + } + + UI.println("Configured providers with quota support:") + for (const id of configured) { + const provider = PROVIDERS[id] + const auth = findAuthForAliases(authMap, provider.aliases) + try { + const result = await provider.fetchQuota(auth!) + displayResult(result) + } catch (e) { + UI.error(`Failed to fetch quota for ${id}: ${e instanceof Error ? e.message : "Unknown error"}`) + } + } + } + }, +}) + +function displayResult(result: QuotaProviderResult) { + if (!result.ok) { + UI.error(` ${result.providerName}: ${result.error}`) + return + } + + if (!result.usage?.windows || Object.keys(result.usage.windows).length === 0) { + UI.println(` ${result.providerName}: No quota information available`) + return + } + + for (const [windowName, window] of Object.entries(result.usage.windows)) { + if (window.valueLabel) { + UI.println(` ${result.providerName}/${windowName}: ${window.valueLabel}`) + continue + } + + if (window.usedPercent !== null) { + const percent = window.usedPercent.toFixed(1) + const resetInfo = window.resetAtFormatted ? ` (resets ${window.resetAtFormatted})` : "" + UI.println(` ${result.providerName}/${windowName}: ${percent}% used${resetInfo}`) + } else { + UI.println(` ${result.providerName}/${windowName}: available`) + } + } +} diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index b3d1db7eb0cb..38e3a64db669 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -18,6 +18,7 @@ import { WorkspaceServeCommand } from "./cli/cmd/workspace-serve" import { Filesystem } from "./util/filesystem" import { DebugCommand } from "./cli/cmd/debug" import { StatsCommand } from "./cli/cmd/stats" +import { QuotaCommand } from "./cli/cmd/quota" import { McpCommand } from "./cli/cmd/mcp" import { GithubCommand } from "./cli/cmd/github" import { ExportCommand } from "./cli/cmd/export" @@ -139,6 +140,7 @@ let cli = yargs(hideBin(process.argv)) .command(WebCommand) .command(ModelsCommand) .command(StatsCommand) + .command(QuotaCommand) .command(ExportCommand) .command(ImportCommand) .command(GithubCommand) From cdcd1d9e28fdab2e8e8c45e9686bc4cea4412d10 Mon Sep 17 00:00:00 2001 From: Xiangfang Chen <565499699@qq.com> Date: Thu, 26 Mar 2026 00:07:23 +0800 Subject: [PATCH 2/2] feat(opencode): add quota command and UI display - Add opencode quota CLI command to query provider quota - Add /global/quota API endpoint - Add Quota tab in Settings dialog (Desktop + Web) - Display usage with progress bars - Update SDK with quota endpoint - Add i18n translations for quota --- .../app/src/components/dialog-settings.tsx | 8 + .../app/src/components/settings-providers.tsx | 117 ++++++++++++--- .../app/src/components/settings-quota.tsx | 141 ++++++++++++++++++ packages/app/src/i18n/en.ts | 4 + packages/app/src/i18n/zh.ts | 4 + packages/opencode/src/cli/cmd/quota.ts | 33 ++++ packages/opencode/src/server/routes/global.ts | 50 +++++++ packages/sdk/js/src/v2/gen/sdk.gen.ts | 13 ++ packages/sdk/js/src/v2/gen/types.gen.ts | 37 +++++ 9 files changed, 386 insertions(+), 21 deletions(-) create mode 100644 packages/app/src/components/settings-quota.tsx diff --git a/packages/app/src/components/dialog-settings.tsx b/packages/app/src/components/dialog-settings.tsx index 83cea131f5db..3c7f7e7fdd7c 100644 --- a/packages/app/src/components/dialog-settings.tsx +++ b/packages/app/src/components/dialog-settings.tsx @@ -8,6 +8,7 @@ import { SettingsGeneral } from "./settings-general" import { SettingsKeybinds } from "./settings-keybinds" import { SettingsProviders } from "./settings-providers" import { SettingsModels } from "./settings-models" +import { SettingsQuota } from "./settings-quota" export const DialogSettings: Component = () => { const language = useLanguage() @@ -45,6 +46,10 @@ export const DialogSettings: Component = () => { {language.t("settings.models.title")} + + + {language.t("settings.quota.title")} + @@ -67,6 +72,9 @@ export const DialogSettings: Component = () => { + + + ) diff --git a/packages/app/src/components/settings-providers.tsx b/packages/app/src/components/settings-providers.tsx index cc69327f80d5..197ca8311f46 100644 --- a/packages/app/src/components/settings-providers.tsx +++ b/packages/app/src/components/settings-providers.tsx @@ -4,7 +4,7 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { Tag } from "@opencode-ai/ui/tag" import { showToast } from "@opencode-ai/ui/toast" import { popularProviders, useProviders } from "@/hooks/use-providers" -import { createMemo, type Component, For, Show } from "solid-js" +import { createMemo, createSignal, onMount, type Component, For, Show } from "solid-js" import { useLanguage } from "@/context/language" import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSync } from "@/context/global-sync" @@ -13,6 +13,32 @@ import { DialogSelectProvider } from "./dialog-select-provider" import { DialogCustomProvider } from "./dialog-custom-provider" import { SettingsList } from "./settings-list" +// Quota types from SDK +interface UsageWindow { + usedPercent: number | null + remainingPercent: number | null + windowSeconds: number | null + resetAfterSeconds: number | null + resetAt: number | null + resetAtFormatted: string | null + resetAfterFormatted: string | null + valueLabel: string | null +} + +interface ProviderUsage { + windows: Record +} + +interface QuotaResult { + providerId: string + providerName: string + ok: boolean + configured: boolean + usage: ProviderUsage | null + error: string | null + fetchedAt: number +} + type ProviderSource = "env" | "api" | "config" | "custom" type ProviderItem = ReturnType["connected"]>[number] @@ -34,6 +60,42 @@ export const SettingsProviders: Component = () => { const globalSync = useGlobalSync() const providers = useProviders() + const [quotaData, setQuotaData] = createSignal([]) + const [quotaLoading, setQuotaLoading] = createSignal(false) + + onMount(async () => { + setQuotaLoading(true) + try { + const response = await globalSDK.client.global.quota() + if (response.data) { + setQuotaData(response.data) + } + } catch (e) { + console.error("Failed to fetch quota:", e) + } finally { + setQuotaLoading(false) + } + }) + + const getQuotaForProvider = (providerId: string): QuotaResult | undefined => { + return quotaData().find((q) => q.providerId === providerId) + } + + const formatQuota = (quota: QuotaResult): string => { + if (!quota.ok || !quota.usage?.windows) return "" + const windows = Object.entries(quota.usage.windows) + if (windows.length === 0) return "" + const parts: string[] = [] + for (const [name, win] of windows) { + if (win.usedPercent !== null) { + parts.push(`${name}: ${win.usedPercent.toFixed(1)}%`) + } else if (win.remainingPercent !== null) { + parts.push(`${name}: ${win.remainingPercent.toFixed(1)}%`) + } + } + return parts.join(", ") + } + const connected = createMemo(() => { return providers .connected() @@ -147,27 +209,40 @@ export const SettingsProviders: Component = () => { } > - {(item) => ( -
-
- - {item.name} - {type(item)} + {(item) => { + const quota = () => getQuotaForProvider(item.id) + const quotaDisplay = () => { + const q = quota() + if (!q) return "" + return formatQuota(q) + } + return ( +
+
+
+ + {item.name} + {type(item)} +
+ + {quotaDisplay()} + +
+ + {language.t("settings.providers.connected.environmentDescription")} + + } + > + +
- - {language.t("settings.providers.connected.environmentDescription")} - - } - > - - -
- )} + ) + }} diff --git a/packages/app/src/components/settings-quota.tsx b/packages/app/src/components/settings-quota.tsx new file mode 100644 index 000000000000..4a51584b96f4 --- /dev/null +++ b/packages/app/src/components/settings-quota.tsx @@ -0,0 +1,141 @@ +import { ProviderIcon } from "@opencode-ai/ui/provider-icon" +import { Progress } from "@opencode-ai/ui/progress" +import { createSignal, onMount, type Component, For, Show } from "solid-js" +import { useLanguage } from "@/context/language" +import { useGlobalSDK } from "@/context/global-sdk" +import { SettingsList } from "./settings-list" + +interface UsageWindow { + usedPercent: number | null + remainingPercent: number | null + windowSeconds: number | null + resetAfterSeconds: number | null + resetAt: number | null + resetAtFormatted: string | null + resetAfterFormatted: string | null + valueLabel: string | null +} + +interface ProviderUsage { + windows: Record +} + +interface QuotaResult { + providerId: string + providerName: string + ok: boolean + configured: boolean + usage: ProviderUsage | null + error: string | null + fetchedAt: number +} + +export const SettingsQuota: Component = () => { + const language = useLanguage() + const globalSDK = useGlobalSDK() + + const [quotaData, setQuotaData] = createSignal([]) + const [loading, setLoading] = createSignal(true) + const [error, setError] = createSignal(null) + + onMount(async () => { + setLoading(true) + setError(null) + try { + const response = await globalSDK.client.global.quota() + if (response.data) { + setQuotaData(response.data) + } + } catch (e) { + console.error("Failed to fetch quota:", e) + setError(e instanceof Error ? e.message : String(e)) + } finally { + setLoading(false) + } + }) + + const formatQuota = (quota: QuotaResult): { label: string; percent: number; remaining: number }[] => { + if (!quota.ok || !quota.usage?.windows) return [] + const windows = Object.entries(quota.usage.windows) + if (windows.length === 0) return [] + const parts: { label: string; percent: number; remaining: number }[] = [] + for (const [name, win] of windows) { + if (win.usedPercent !== null) { + parts.push({ label: name, percent: win.usedPercent, remaining: 100 - win.usedPercent }) + } else if (win.remainingPercent !== null) { + parts.push({ label: name, percent: 100 - win.remainingPercent, remaining: win.remainingPercent }) + } + } + return parts + } + + return ( +
+
+
+

{language.t("settings.quota.title")}

+
+
+ +
+ +
Loading...
+ + } + > + + +
{error()}
+
+
+ + + 0} + fallback={ + +
{language.t("settings.quota.empty")}
+
+ } + > + + + {(quota) => { + const items = () => formatQuota(quota) + return ( +
+
+
+ + {quota.providerName} +
+ 0}> +
+ + {(item) => ( + + {item.label} + + )} + +
+
+ + {quota.error} + +
+
+ ) + }} +
+
+
+
+
+
+
+ ) +} diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index bdf97ec0fea5..6257a0f81ce3 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -868,6 +868,10 @@ export const dict = { "settings.providers.tag.other": "Other", "settings.models.title": "Models", "settings.models.description": "Model settings will be configurable here.", + + "settings.quota.title": "Quota", + "settings.quota.empty": "No providers with quota available", + "settings.agents.title": "Agents", "settings.agents.description": "Agent settings will be configurable here.", "settings.commands.title": "Commands", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index 2a7ababb2b31..6d3b12cde8e6 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -761,6 +761,10 @@ export const dict = { "settings.models.title": "模型", "settings.models.description": "模型设置将在此处可配置。", + "settings.quota.title": "配额", + "settings.quota.empty": "没有可用的配额信息", + + "settings.agents.title": "智能体", "settings.agents.description": "智能体设置将在此处可配置。", diff --git a/packages/opencode/src/cli/cmd/quota.ts b/packages/opencode/src/cli/cmd/quota.ts index 6b0150ca04b4..dfa059016caa 100644 --- a/packages/opencode/src/cli/cmd/quota.ts +++ b/packages/opencode/src/cli/cmd/quota.ts @@ -1266,6 +1266,10 @@ function findAuthForAliases(authMap: Record, aliases: string[ return undefined } +// Export for use in API endpoints +export { PROVIDERS, findAuthForAliases } +export type { QuotaProviderResult, UsageWindow, ProviderUsage } + export const QuotaCommand = cmd({ command: "quota [provider]", describe: "show quota usage for providers", @@ -1347,3 +1351,32 @@ function displayResult(result: QuotaProviderResult) { } } } + +// Get quota for all configured providers (for API use) +export async function getAllQuota(): Promise { + const authMap = await Auth.all() + const results: QuotaProviderResult[] = [] + + for (const [id, provider] of Object.entries(PROVIDERS)) { + const auth = findAuthForAliases(authMap, provider.aliases) + const isConfigured = await provider.isConfigured(auth) + if (isConfigured) { + try { + const result = await provider.fetchQuota(auth!) + results.push(result) + } catch (e) { + results.push({ + providerId: id, + providerName: provider.providerName, + ok: false, + configured: true, + usage: null, + error: e instanceof Error ? e.message : "Unknown error", + fetchedAt: Date.now(), + }) + } + } + } + + return results +} diff --git a/packages/opencode/src/server/routes/global.ts b/packages/opencode/src/server/routes/global.ts index 88f54f844ad7..ece821285da1 100644 --- a/packages/opencode/src/server/routes/global.ts +++ b/packages/opencode/src/server/routes/global.ts @@ -12,6 +12,7 @@ import { Log } from "../../util/log" import { lazy } from "../../util/lazy" import { Config } from "../../config/config" import { errors } from "../error" +import { getAllQuota, type QuotaProviderResult } from "../../cli/cmd/quota" const log = Log.create({ service: "server" }) @@ -306,5 +307,54 @@ export const GlobalRoutes = lazy(() => } return c.json(result, 500) }, + ) + .get( + "/quota", + describeRoute({ + summary: "Get provider quota", + description: "Get quota information for all configured providers.", + operationId: "global.quota", + responses: { + 200: { + description: "Quota information", + content: { + "application/json": { + schema: resolver(z.array(z.lazy(() => quotaResultSchema))), + }, + }, + }, + }, + }), + async (c) => { + const results = await getAllQuota() + return c.json(results) + }, ), ) + +// Quota result schema +const quotaResultSchema = z.object({ + providerId: z.string(), + providerName: z.string(), + ok: z.boolean(), + configured: z.boolean(), + usage: z + .object({ + windows: z.record( + z.string(), + z.object({ + usedPercent: z.number().nullable(), + remainingPercent: z.number().nullable(), + windowSeconds: z.number().nullable(), + resetAfterSeconds: z.number().nullable(), + resetAt: z.number().nullable(), + resetAtFormatted: z.string().nullable(), + resetAfterFormatted: z.string().nullable(), + valueLabel: z.string().nullable(), + }), + ), + }) + .nullable(), + error: z.string().nullable(), + fetchedAt: z.number(), +}) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index bcaa84dd6833..8f54dc7c02fd 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -46,6 +46,7 @@ import type { GlobalDisposeResponses, GlobalEventResponses, GlobalHealthResponses, + GlobalQuotaResponses, GlobalSyncEventSubscribeResponses, GlobalUpgradeErrors, GlobalUpgradeResponses, @@ -345,6 +346,18 @@ export class Global extends HeyApiClient { }) } + /** + * Get provider quota + * + * Get quota information for all configured providers. + */ + public quota(options?: Options) { + return (options?.client ?? this.client).get({ + url: "/global/quota", + ...options, + }) + } + private _syncEvent?: SyncEvent get syncEvent(): SyncEvent { return (this._syncEvent ??= new SyncEvent({ client: this.client })) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index e901acce644f..7f871b026df5 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -2188,6 +2188,43 @@ export type GlobalUpgradeResponses = { export type GlobalUpgradeResponse = GlobalUpgradeResponses[keyof GlobalUpgradeResponses] +export type GlobalQuotaData = { + body?: never + path?: never + query?: never + url: "/global/quota" +} + +export type GlobalQuotaResponses = { + /** + * Quota information + */ + 200: Array<{ + providerId: string + providerName: string + ok: boolean + configured: boolean + usage: { + windows: { + [key: string]: { + usedPercent: number | null + remainingPercent: number | null + windowSeconds: number | null + resetAfterSeconds: number | null + resetAt: number | null + resetAtFormatted: string | null + resetAfterFormatted: string | null + valueLabel: string | null + } + } + } | null + error: string | null + fetchedAt: number + }> +} + +export type GlobalQuotaResponse = GlobalQuotaResponses[keyof GlobalQuotaResponses] + export type AuthRemoveData = { body?: never path: {