diff --git a/open-sse/providers/registry/groq.js b/open-sse/providers/registry/groq.js index 2ad8a6d8..b1c04538 100644 --- a/open-sse/providers/registry/groq.js +++ b/open-sse/providers/registry/groq.js @@ -17,6 +17,12 @@ export default { transport: { baseUrl: "https://api.groq.com/openai/v1/chat/completions", validateUrl: "https://api.groq.com/openai/v1/models", + // No dedicated quota endpoint; rate-limit info rides on x-ratelimit-* + // response headers, always included. Reuse the models list (already + // used as validateUrl) so reading usage never costs tokens. + usage: { + url: "https://api.groq.com/openai/v1/models", + }, }, models: [ { id: "llama-3.3-70b-versatile", name: "Llama 3.3 70B" }, @@ -34,4 +40,8 @@ export default { authHeader: "bearer", format: "openai", }, + features: { + usage: true, + usageApikey: true, + }, }; diff --git a/open-sse/services/usage.js b/open-sse/services/usage.js index 22fd947e..51f00898 100644 --- a/open-sse/services/usage.js +++ b/open-sse/services/usage.js @@ -14,6 +14,7 @@ import { getCodeBuddyCnUsage, getCodeBuddyIntlUsage } from "./usage/codebuddy-cn import { getGrokCliUsage } from "./usage/grok-cli.js"; import { getKimiUsage } from "./usage/kimi.js"; import { getDeepseekUsage } from "./usage/deepseek.js"; +import { getGroqUsage } from "./usage/groq.js"; import { getZedUsage } from "./usage/zed.js"; import { resolveQoderCredentials } from "./qoderModels.js"; import { getGlmUsage } from "./usage/glm.js"; @@ -55,6 +56,7 @@ const USAGE_HANDLERS = { "grok-cli": (c) => getGrokCliUsage(c.accessToken, c.providerSpecificData, c.proxyOptions), kimi: (c) => getKimiUsage(c.accessToken, c.apiKey, c.proxyOptions, c.providerSpecificData), deepseek: (c) => getDeepseekUsage(c.apiKey, c.proxyOptions), + groq: (c) => getGroqUsage(c.apiKey, c.proxyOptions), zed: (c) => getZedUsage(c.accessToken, c.providerSpecificData, c.proxyOptions), }; diff --git a/open-sse/services/usage/groq.js b/open-sse/services/usage/groq.js new file mode 100644 index 00000000..61be0892 --- /dev/null +++ b/open-sse/services/usage/groq.js @@ -0,0 +1,133 @@ +/** + * Groq usage — no dedicated quota endpoint. Rate-limit info instead rides on + * every API response as x-ratelimit-* headers (requests + tokens, always + * included). We piggyback on the models list (already used as + * transport.validateUrl) so reading usage never costs tokens. + * + * Headers: + * x-ratelimit-limit-requests / x-ratelimit-remaining-requests + * x-ratelimit-limit-tokens / x-ratelimit-remaining-tokens + * x-ratelimit-reset-requests / x-ratelimit-reset-tokens (duration strings, e.g. "2m59.56s") + * + * Docs: https://console.groq.com/docs/rate-limits + */ + +import { proxyAwareFetch } from "../../utils/proxyFetch.js"; +import { U } from "./shared.js"; + +const MODELS_URL = U("groq").url; + +// Groq reset headers are Go-style duration strings ("2m59.56s", "7.66s"), not +// timestamps — parse the h/m/s/ms components and add them to now(). +function parseGroqDurationMs(value) { + if (typeof value !== "string" || !value.trim()) return null; + + const re = /(\d+(?:\.\d+)?)(ms|s|m|h)/g; + let match; + let totalMs = 0; + let matched = false; + while ((match = re.exec(value))) { + matched = true; + const amount = Number(match[1]); + const unit = match[2]; + const unitMs = unit === "h" ? 3600000 : unit === "m" ? 60000 : unit === "ms" ? 1 : 1000; + totalMs += amount * unitMs; + } + return matched ? totalMs : null; +} + +function resetAtFromDuration(value) { + const ms = parseGroqDurationMs(value); + return ms === null ? null : new Date(Date.now() + ms).toISOString(); +} + +function buildRateLimitQuota(headers, limitKey, remainingKey, resetKey) { + // headers.get() returns null when absent, and Number(null) is 0 (a finite + // number) — check presence explicitly so a missing header can't masquerade + // as a real "0 remaining" quota. + const limitRaw = headers.get(limitKey); + const remainingRaw = headers.get(remainingKey); + if (limitRaw === null || remainingRaw === null) return null; + + const limit = Number(limitRaw); + const remaining = Number(remainingRaw); + if (!Number.isFinite(limit) || !Number.isFinite(remaining)) return null; + + return { + used: Math.max(0, limit - remaining), + total: limit, + resetAt: resetAtFromDuration(headers.get(resetKey)), + unlimited: false, + }; +} + +/** + * @param {string|null|undefined} apiKey + * @param {object|null} proxyOptions + */ +export async function getGroqUsage(apiKey, proxyOptions = null) { + if (!apiKey || typeof apiKey !== "string" || !apiKey.trim()) { + return { message: "Groq API key not available. Add a key to view usage." }; + } + + try { + const response = await proxyAwareFetch( + MODELS_URL, + { + method: "GET", + headers: { + Authorization: `Bearer ${apiKey.trim()}`, + Accept: "application/json", + }, + }, + proxyOptions, + ); + + if (response.status === 401 || response.status === 403) { + return { plan: "Groq", message: "Groq authentication failed. Check the API key." }; + } + + if (!response.ok) { + const errText = await response.text().catch(() => ""); + return { + plan: "Groq", + message: `Groq usage API error (${response.status})${errText ? `: ${errText.slice(0, 120)}` : ""}`, + }; + } + + // The quota data lives in headers, not the body — drain it so the + // connection can be released without needing the payload. + await response.text().catch(() => {}); + + const requests = buildRateLimitQuota( + response.headers, + "x-ratelimit-limit-requests", + "x-ratelimit-remaining-requests", + "x-ratelimit-reset-requests", + ); + const tokens = buildRateLimitQuota( + response.headers, + "x-ratelimit-limit-tokens", + "x-ratelimit-remaining-tokens", + "x-ratelimit-reset-tokens", + ); + + if (!requests && !tokens) { + // Key is valid (request succeeded) but no rate-limit bucket reported — + // distinguish "not tracked yet" from an auth/error state. + return { + plan: "Groq", + message: "Groq connected. No rate-limit data reported for this key yet.", + quotas: {}, + }; + } + + const quotas = {}; + if (requests) quotas["Requests"] = requests; + if (tokens) quotas["Tokens"] = tokens; + + return { plan: "Groq", quotas }; + } catch (error) { + return { message: `Groq error: ${error.message}` }; + } +} diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js index 0d031450..2a1a9236 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js @@ -542,6 +542,21 @@ export function parseQuotaData(provider, data) { } break; + case "groq": + // Requests/Tokens rate-limit windows from response headers — absolute + // used/total (calculatePercentage derives the bar), like Codex/Kiro. + if (data.quotas) { + Object.entries(data.quotas).forEach(([name, quota]) => { + normalizedQuotas.push({ + name, + used: quota.used || 0, + total: quota.total || 0, + resetAt: quota.resetAt || null, + }); + }); + } + break; + case "ollama": // Session (5h) / Weekly (7d) usage % from ollama.com/api/usage. // remainingPercentage only — no absolute remaining (UI treats remaining as %). diff --git a/tests/unit/groq-usage.test.js b/tests/unit/groq-usage.test.js new file mode 100644 index 00000000..10b8ad82 --- /dev/null +++ b/tests/unit/groq-usage.test.js @@ -0,0 +1,127 @@ +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 } from "../../src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js"; + +const MODELS_URL = "https://api.groq.com/openai/v1/models"; + +function response(body, { status = 200, headers = {} } = {}) { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json", ...headers }, + }); +} + +const RATE_LIMIT_HEADERS = { + "x-ratelimit-limit-requests": "14400", + "x-ratelimit-remaining-requests": "14370", + "x-ratelimit-reset-requests": "2m59.56s", + "x-ratelimit-limit-tokens": "18000", + "x-ratelimit-remaining-tokens": "17997", + "x-ratelimit-reset-tokens": "7.66s", +}; + +describe("groq registry usage flags", () => { + it("is listed for apikey quota dashboard", () => { + expect(USAGE_SUPPORTED_PROVIDERS).toContain("groq"); + expect(USAGE_APIKEY_PROVIDERS).toContain("groq"); + }); +}); + +describe("getUsageForProvider(groq)", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("GETs the models endpoint with Bearer apiKey", async () => { + proxyAwareFetch.mockResolvedValueOnce( + response({ data: [] }, { headers: RATE_LIMIT_HEADERS }), + ); + + const usage = await getUsageForProvider({ + provider: "groq", + apiKey: "gsk_test", + }); + + expect(usage.message).toBeUndefined(); + expect(usage.plan).toBe("Groq"); + expect(proxyAwareFetch).toHaveBeenCalledTimes(1); + const [url, opts] = proxyAwareFetch.mock.calls[0]; + expect(url).toBe(MODELS_URL); + expect(opts.method).toBe("GET"); + expect(opts.headers.Authorization).toBe("Bearer gsk_test"); + }); + + it("parses request + token rate-limit headers into quotas", async () => { + proxyAwareFetch.mockResolvedValueOnce( + response({ data: [] }, { headers: RATE_LIMIT_HEADERS }), + ); + + const usage = await getUsageForProvider({ + provider: "groq", + apiKey: "gsk_test", + }); + + expect(usage.quotas["Requests"]).toMatchObject({ + used: 30, + total: 14400, + unlimited: false, + }); + expect(usage.quotas["Tokens"]).toMatchObject({ + used: 3, + total: 18000, + unlimited: false, + }); + // Duration-string reset headers resolve to a real future ISO timestamp. + expect(new Date(usage.quotas["Requests"].resetAt).getTime()).toBeGreaterThan(Date.now()); + expect(new Date(usage.quotas["Tokens"].resetAt).getTime()).toBeGreaterThan(Date.now()); + }); + + it("returns a soft message (not an error) when no rate-limit headers are present", async () => { + proxyAwareFetch.mockResolvedValueOnce(response({ data: [] })); + + const usage = await getUsageForProvider({ + provider: "groq", + apiKey: "gsk_test", + }); + + expect(usage.error).toBeUndefined(); + expect(usage.message).toMatch(/no rate-limit data/i); + expect(usage.quotas).toEqual({}); + }); + + it("returns message on missing key / 401", async () => { + const missing = await getUsageForProvider({ provider: "groq" }); + expect(missing.message).toMatch(/api key/i); + expect(proxyAwareFetch).not.toHaveBeenCalled(); + + proxyAwareFetch.mockResolvedValueOnce(response({ error: "invalid_api_key" }, { status: 401 })); + const auth = await getUsageForProvider({ provider: "groq", apiKey: "bad" }); + expect(auth.message).toMatch(/auth|key/i); + }); +}); + +describe("parseQuotaData(groq)", () => { + it("forwards used/total/resetAt for the dashboard table", () => { + const rows = parseQuotaData("groq", { + plan: "Groq", + quotas: { + Requests: { used: 30, total: 14400, resetAt: "2026-01-01T00:03:00.000Z" }, + Tokens: { used: 3, total: 18000, resetAt: "2026-01-01T00:00:08.000Z" }, + }, + }); + + expect(rows).toHaveLength(2); + expect(rows[0]).toMatchObject({ name: "Requests", used: 30, total: 14400 }); + expect(rows[1]).toMatchObject({ name: "Tokens", used: 3, total: 18000 }); + }); +});