From 38f031f4c9bd50d16b760cf7b831e724ee0c1e7f Mon Sep 17 00:00:00 2001 From: decolua Date: Tue, 1 Sep 2026 10:45:51 +0700 Subject: [PATCH 01/31] feat(models): capability toggles for custom models with upsert and live caps refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AddCustomModelModal lets users pick vision/reasoning caps when adding a model - POST /api/models/custom whitelists caps to booleans - aliasRepo.addCustomModel upserts — re-adding updates caps/name in place - /api/models includes custom llm models with stored caps overriding the heuristic - useModelCaps refetches on customModelChanged instead of trusting a stale cache Co-Authored-By: Claude Code --- .../providers/[id]/AddCustomModelModal.js | 26 +++++++++++++++-- .../dashboard/providers/[id]/page.js | 8 ++--- src/app/api/models/custom/route.js | 16 ++++++++-- src/app/api/models/route.js | 29 ++++++++++++++++++- src/lib/db/repos/aliasRepo.js | 16 ++++++---- src/shared/hooks/useModelCaps.js | 25 +++++++++++----- 6 files changed, 97 insertions(+), 23 deletions(-) diff --git a/src/app/(dashboard)/dashboard/providers/[id]/AddCustomModelModal.js b/src/app/(dashboard)/dashboard/providers/[id]/AddCustomModelModal.js index d0bf1659..3c9b21d0 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/AddCustomModelModal.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/AddCustomModelModal.js @@ -2,17 +2,21 @@ import { useState, useEffect } from "react"; import PropTypes from "prop-types"; -import { Button, Modal } from "@/shared/components"; +import { Button, Modal, Toggle } from "@/shared/components"; +import { CAPACITY_META } from "@/shared/constants/models"; + +const defaultCaps = () => Object.fromEntries(Object.keys(CAPACITY_META).map((key) => [key, false])); export default function AddCustomModelModal({ isOpen, providerAlias, providerDisplayAlias, onSave, onClose }) { const [modelId, setModelId] = useState(""); + const [caps, setCaps] = useState(defaultCaps); const [testStatus, setTestStatus] = useState(null); // null | "testing" | "ok" | "error" const [testError, setTestError] = useState(""); const [saving, setSaving] = useState(false); // Reset state when modal opens useEffect(() => { - if (isOpen) { setModelId(""); setTestStatus(null); setTestError(""); } + if (isOpen) { setModelId(""); setCaps(defaultCaps()); setTestStatus(null); setTestError(""); } }, [isOpen]); // Strip provider's own alias prefix (e.g. "cc/model" -> "model" for cc provider) @@ -46,7 +50,7 @@ export default function AddCustomModelModal({ isOpen, providerAlias, providerDis if (!cleanId || saving) return; setSaving(true); try { - await onSave(cleanId); + await onSave(cleanId, caps); } finally { setSaving(false); } @@ -86,6 +90,22 @@ export default function AddCustomModelModal({ isOpen, providerAlias, providerDis

+
+ +
+ {Object.entries(CAPACITY_META).map(([key, meta]) => ( + setCaps((prev) => ({ ...prev, [key]: v }))} + label={meta.label} + description={meta.desc} + size="sm" + /> + ))} +
+
+ {/* Test result */} {testStatus === "ok" && (
diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.js b/src/app/(dashboard)/dashboard/providers/[id]/page.js index 4b4ad014..b7c802c0 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.js @@ -527,12 +527,12 @@ export default function ProviderDetailPage() { } }; - const handleAddCustomModel = async (modelId, type = "llm", providerAliasOverride = providerStorageAlias) => { + const handleAddCustomModel = async (modelId, type = "llm", providerAliasOverride = providerStorageAlias, caps) => { try { const res = await fetch("/api/models/custom", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ providerAlias: providerAliasOverride, id: modelId, type }), + body: JSON.stringify({ providerAlias: providerAliasOverride, id: modelId, type, ...(caps ? { caps } : {}) }), }); if (res.ok) { await fetchCustomModels(); @@ -1781,8 +1781,8 @@ export default function ProviderDetailPage() { isOpen={showAddCustomModel} providerAlias={providerStorageAlias} providerDisplayAlias={providerDisplayAlias} - onSave={async (modelId) => { - await handleAddCustomModel(modelId, "llm", providerStorageAlias); + onSave={async (modelId, caps) => { + await handleAddCustomModel(modelId, "llm", providerStorageAlias, caps); setShowAddCustomModel(false); }} onClose={() => setShowAddCustomModel(false)} diff --git a/src/app/api/models/custom/route.js b/src/app/api/models/custom/route.js index 76e14c96..ce917d76 100644 --- a/src/app/api/models/custom/route.js +++ b/src/app/api/models/custom/route.js @@ -1,8 +1,19 @@ import { NextResponse } from "next/server"; import { getCustomModels, addCustomModel, deleteCustomModel } from "@/models"; +import { CAPACITY_META } from "@/shared/constants/models"; export const dynamic = "force-dynamic"; +// Whitelist capability keys to boolean values — ignore anything else +function sanitizeCaps(caps) { + if (!caps || typeof caps !== "object") return null; + const clean = {}; + for (const key of Object.keys(CAPACITY_META)) { + if (typeof caps[key] === "boolean") clean[key] = caps[key]; + } + return Object.keys(clean).length ? clean : null; +} + // GET /api/models/custom - List all custom models export async function GET() { try { @@ -17,11 +28,12 @@ export async function GET() { // POST /api/models/custom - Add custom model export async function POST(request) { try { - const { providerAlias, id, type, name } = await request.json(); + const { providerAlias, id, type, name, caps } = await request.json(); if (!providerAlias || !id) { return NextResponse.json({ error: "providerAlias and id required" }, { status: 400 }); } - const added = await addCustomModel({ providerAlias, id, type: type || "llm", name }); + const cleanCaps = sanitizeCaps(caps); + const added = await addCustomModel({ providerAlias, id, type: type || "llm", name, ...(cleanCaps ? { caps: cleanCaps } : {}) }); return NextResponse.json({ success: true, added }); } catch (error) { console.log("Error adding custom model:", error); diff --git a/src/app/api/models/route.js b/src/app/api/models/route.js index b2e4fe0a..ce5619f9 100644 --- a/src/app/api/models/route.js +++ b/src/app/api/models/route.js @@ -1,5 +1,5 @@ import { NextResponse } from "next/server"; -import { getModelAliases, setModelAlias } from "@/models"; +import { getModelAliases, setModelAlias, getCustomModels } from "@/models"; import { getDisabledModels } from "@/lib/disabledModelsDb"; import { AI_MODELS } from "@/shared/constants/config"; import { getProviderAlias } from "@/shared/constants/providers"; @@ -37,6 +37,33 @@ export async function GET() { }; }); + // Custom models ride along; their stored caps override the name heuristic + const seenFull = new Set(models.map((m) => m.fullModel)); + const customModels = (await getCustomModels()).filter((m) => { + if (!m?.id || (m.kind || m.type || "llm") !== "llm") return false; + return !seenFull.has(`${m.providerAlias}/${m.id}`); + }); + for (const m of customModels) { + const fullModel = `${m.providerAlias}/${m.id}`; + const c = getCapabilitiesForModel(m.providerAlias, m.id); + models.push({ + provider: m.providerAlias, + model: m.id, + name: m.name || m.id, + fullModel, + routedModel: fullModel, + alias: modelAliases[fullModel] || m.id, + caps: { + vision: c.vision, + search: c.search, + reasoning: c.reasoning, + contextWindow: c.contextWindow, + maxOutput: c.maxOutput, + ...(m.caps || {}), + }, + }); + } + return NextResponse.json({ models }); } catch (error) { console.log("Error fetching models:", error); diff --git a/src/lib/db/repos/aliasRepo.js b/src/lib/db/repos/aliasRepo.js index fe4ef22f..3cfafa83 100644 --- a/src/lib/db/repos/aliasRepo.js +++ b/src/lib/db/repos/aliasRepo.js @@ -29,15 +29,21 @@ export async function getCustomModels() { return Object.values(all); } -// Atomic check-then-insert inside transaction to prevent duplicate races -export async function addCustomModel({ providerAlias, id, type = "llm", name }) { +// Atomic upsert inside transaction to prevent duplicate races. +// Re-adding an existing model updates caps/name without resetting omitted fields. +export async function addCustomModel({ providerAlias, id, type = "llm", name, caps }) { const k = customKey(providerAlias, id, type); const db = await getAdapter(); let added = false; db.transaction(() => { - const row = db.get(`SELECT 1 FROM kv WHERE scope = 'customModels' AND key = ?`, [k]); - if (row) return; - const value = stringifyJson({ providerAlias, id, type, name: name || id }); + const row = db.get(`SELECT value FROM kv WHERE scope = 'customModels' AND key = ?`, [k]); + if (row) { + const prev = parseJson(row.value) || {}; + const next = { ...prev, ...(name ? { name } : {}), ...(caps ? { caps } : {}) }; + db.run(`UPDATE kv SET value = ? WHERE scope = 'customModels' AND key = ?`, [stringifyJson(next), k]); + return; + } + const value = stringifyJson({ providerAlias, id, type, name: name || id, ...(caps ? { caps } : {}) }); db.run(`INSERT INTO kv(scope, key, value) VALUES('customModels', ?, ?)`, [k, value]); added = true; }); diff --git a/src/shared/hooks/useModelCaps.js b/src/shared/hooks/useModelCaps.js index 92bcec21..88c6410f 100644 --- a/src/shared/hooks/useModelCaps.js +++ b/src/shared/hooks/useModelCaps.js @@ -59,16 +59,25 @@ export function useModelCaps() { const [byId, setById] = useState(() => cache?.byId || {}); useEffect(() => { - if (cache) { - setByFull(cache.byFull); - setById(cache.byId); - return; - } let alive = true; - loadModelCaps().then((maps) => { + const sync = (maps) => { if (alive) { setByFull(maps.byFull); setById(maps.byId); } - }); - return () => { alive = false; }; + }; + if (cache) { + sync(cache); + } else { + loadModelCaps().then(sync); + } + // Custom models change at runtime — drop the shared cache and refetch + const invalidate = () => { + cache = null; + loadModelCaps().then(sync); + }; + window.addEventListener("customModelChanged", invalidate); + return () => { + alive = false; + window.removeEventListener("customModelChanged", invalidate); + }; }, []); const getCaps = useCallback( From 009cac6326e1726da0b9f97ac08d7a16120ceab0 Mon Sep 17 00:00:00 2001 From: decolua Date: Wed, 2 Sep 2026 11:27:21 +0700 Subject: [PATCH 02/31] fix(claude): bump CC fingerprint to 2.1.258 for new-model access Anthropic gates newly released models (e.g. claude-fable-5-1) to Claude Code >= 2.1.251; the spoofed 2.1.92 client got HTTP 400 on every request. Bump User-Agent + billing-header version to 2.1.258 and refresh the providers baseline snapshot. Co-Authored-By: Claude Code --- open-sse/providers/registry/claude.js | 2 +- open-sse/providers/shared.js | 2 +- open-sse/utils/claudeCloaking.js | 6 +++--- tests/__baseline__/providers-baseline.json | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/open-sse/providers/registry/claude.js b/open-sse/providers/registry/claude.js index 8c47a6b3..65bab07e 100644 --- a/open-sse/providers/registry/claude.js +++ b/open-sse/providers/registry/claude.js @@ -25,7 +25,7 @@ export default { "Anthropic-Version": "2023-06-01", "Anthropic-Beta": "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05,advanced-tool-use-2025-11-20,effort-2025-11-24,structured-outputs-2025-12-15,fast-mode-2026-02-01,redact-thinking-2026-02-12,token-efficient-tools-2026-03-28", "Anthropic-Dangerous-Direct-Browser-Access": "true", - "User-Agent": "claude-cli/2.1.92 (external, sdk-cli)", + "User-Agent": "claude-cli/2.1.258 (external, sdk-cli)", "X-App": "cli", "X-Stainless-Helper-Method": "stream", "X-Stainless-Retry-Count": "0", diff --git a/open-sse/providers/shared.js b/open-sse/providers/shared.js index fdd76c40..118a7593 100644 --- a/open-sse/providers/shared.js +++ b/open-sse/providers/shared.js @@ -34,7 +34,7 @@ export const CLAUDE_CLI_SPOOF_HEADERS = { "Anthropic-Version": ANTHROPIC_API_VERSION, "Anthropic-Beta": "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05,advanced-tool-use-2025-11-20,effort-2025-11-24,structured-outputs-2025-12-15,fast-mode-2026-02-01,redact-thinking-2026-02-12,token-efficient-tools-2026-03-28", "Anthropic-Dangerous-Direct-Browser-Access": "true", - "User-Agent": "claude-cli/2.1.92 (external, sdk-cli)", + "User-Agent": "claude-cli/2.1.258 (external, sdk-cli)", "X-App": "cli", "X-Stainless-Helper-Method": "stream", "X-Stainless-Retry-Count": "0", diff --git a/open-sse/utils/claudeCloaking.js b/open-sse/utils/claudeCloaking.js index 4f43a670..50395d53 100644 --- a/open-sse/utils/claudeCloaking.js +++ b/open-sse/utils/claudeCloaking.js @@ -1,10 +1,10 @@ import { createHash, randomBytes, randomUUID } from "crypto"; import { CLAUDE_TOOL_SUFFIX, CC_DEFAULT_TOOLS } from "../config/appConstants.js"; -const CLAUDE_VERSION = "2.1.92"; +const CLAUDE_VERSION = "2.1.258"; const CC_ENTRYPOINT = "sdk-cli"; -// Generate billing header matching real Claude Code 2.1.92+ format: +// Generate billing header matching real Claude Code 2.1.258+ format: // x-anthropic-billing-header: cc_version=.; cc_entrypoint=sdk-cli; cch=; function generateBillingHeader(payload) { const content = JSON.stringify(payload); @@ -19,7 +19,7 @@ function deriveUuid(seed) { return `${h.slice(0, 8)}-${h.slice(8, 12)}-4${h.slice(13, 16)}-${((parseInt(h[16], 16) & 0x3) | 0x8).toString(16)}${h.slice(17, 20)}-${h.slice(20, 32)}`; } -// Generate fake user ID in Claude Code 2.1.92+ JSON format: +// Generate fake user ID in Claude Code 2.1.258+ JSON format: // {"device_id":"<64hex>","account_uuid":"","session_id":""} // device_id/account_uuid derive from apiKey (stable per account), session_id per-conversation function generateFakeUserID(sessionId, apiKey) { diff --git a/tests/__baseline__/providers-baseline.json b/tests/__baseline__/providers-baseline.json index 2bf96d44..caeaa911 100644 --- a/tests/__baseline__/providers-baseline.json +++ b/tests/__baseline__/providers-baseline.json @@ -92,7 +92,7 @@ "Anthropic-Version": "2023-06-01", "Anthropic-Beta": "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05,advanced-tool-use-2025-11-20,effort-2025-11-24,structured-outputs-2025-12-15,fast-mode-2026-02-01,redact-thinking-2026-02-12,token-efficient-tools-2026-03-28", "Anthropic-Dangerous-Direct-Browser-Access": "true", - "User-Agent": "claude-cli/2.1.92 (external, sdk-cli)", + "User-Agent": "claude-cli/2.1.258 (external, sdk-cli)", "X-App": "cli", "X-Stainless-Helper-Method": "stream", "X-Stainless-Retry-Count": "0", From 9d3f7646d1219b2ec6220573a001842adea5187d Mon Sep 17 00:00:00 2001 From: dajinglingpake <1753473884@qq.com> Date: Wed, 2 Sep 2026 19:44:23 +0700 Subject: [PATCH 03/31] fix(i18n): translate combo vision adapter label --- public/i18n/literals/zh-CN.json | 1 + 1 file changed, 1 insertion(+) diff --git a/public/i18n/literals/zh-CN.json b/public/i18n/literals/zh-CN.json index da045742..5de38ff0 100644 --- a/public/i18n/literals/zh-CN.json +++ b/public/i18n/literals/zh-CN.json @@ -235,6 +235,7 @@ "Codex CLI not installed": "Codex CLI 未安装", "Codex Reset Credit Expiry": "Codex 重置信用有效期", "Codex uses": "Codex 使用", + "Combo & Vision Adapter": "组合与视觉适配器", "Combo Name": "组合名称", "Combo Round Robin": "组合轮询", "Combo Sticky Limit": "组合粘性限制", From 44e4b80bbee099fea4f81ab783850309cfec1cf6 Mon Sep 17 00:00:00 2001 From: decolua Date: Wed, 2 Sep 2026 20:01:15 +0700 Subject: [PATCH 04/31] fix(models): filter dead opencode free model Co-Authored-By: Claude Code --- src/app/api/providers/suggested-models/filters.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/app/api/providers/suggested-models/filters.js b/src/app/api/providers/suggested-models/filters.js index 8299f46b..13454757 100644 --- a/src/app/api/providers/suggested-models/filters.js +++ b/src/app/api/providers/suggested-models/filters.js @@ -1,6 +1,9 @@ // Free OpenCode models that don't use the "-free" id suffix const KNOWN_FREE_OPENCODE_MODELS = ["big-pickle"]; +// Upstream returns "Model is unavailable" for this id (2026-09-02) — re-enable when fixed +const DEAD_FREE_OPENCODE_MODELS = new Set(["deepseek-v4-flash-free"]); + export const FILTERS = { "openrouter-free": (models) => models @@ -15,7 +18,7 @@ export const FILTERS = { "opencode-free": (models) => models - .filter((m) => m.id?.endsWith("-free") || KNOWN_FREE_OPENCODE_MODELS.includes(m.id)) + .filter((m) => (m.id?.endsWith("-free") || KNOWN_FREE_OPENCODE_MODELS.includes(m.id)) && !DEAD_FREE_OPENCODE_MODELS.has(m.id)) .map((m) => ({ id: m.id, name: m.id })), // models.dev returns a large catalog; keep only mimo models From b9c92cb83c0a236aff954ecc2ef0e1dfca2a9827 Mon Sep 17 00:00:00 2001 From: openhands Date: Wed, 2 Sep 2026 20:03:44 +0700 Subject: [PATCH 05/31] feat(quota): add usage tracking for Groq First slice of #3701: quota tracking for Groq via x-ratelimit-* response headers on the models endpoint (no dedicated quota endpoint exists, and reading usage costs zero tokens). - usage/groq.js: parse request+token limit/remaining headers; Go-style duration reset headers ("2m59.56s") resolve to future timestamps; missing key/401/403 -> message, 2xx without headers -> soft "not tracked yet" with quotas:{} - registry/groq.js: transport.usage.url (reuses validateUrl) + features {usage, usageApikey} - services/usage.js: groq entry in USAGE_HANDLERS - ProviderLimits/utils.js: parseQuotaData case (absolute used/total, codex/kiro style) - tests: groq-usage.test.js (registry flags, header parsing, soft not-tracked path, missing key/401, parseQuotaData) --- open-sse/providers/registry/groq.js | 10 ++ open-sse/services/usage.js | 2 + open-sse/services/usage/groq.js | 133 ++++++++++++++++++ .../usage/components/ProviderLimits/utils.js | 15 ++ tests/unit/groq-usage.test.js | 127 +++++++++++++++++ 5 files changed, 287 insertions(+) create mode 100644 open-sse/services/usage/groq.js create mode 100644 tests/unit/groq-usage.test.js 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 }); + }); +}); From 925cb4aadee2e6f2ae35a867bd878656531f4975 Mon Sep 17 00:00:00 2001 From: docaohieu2808 Date: Wed, 2 Sep 2026 20:05:18 +0700 Subject: [PATCH 06/31] fix(ui): apply persisted theme before first paint to avoid flash on reload Theme was applied from the client store in useEffect (after hydration), so a reload painted the default light theme for a frame before the stored dark theme was reapplied. Add a blocking head script that reads the persisted zustand theme key and sets the dark class on documentElement before first paint, mirroring applyTheme() including system -> prefers-color-scheme resolution. --- src/app/layout.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/app/layout.js b/src/app/layout.js index 8e9b7609..a6534188 100644 --- a/src/app/layout.js +++ b/src/app/layout.js @@ -32,6 +32,14 @@ export default function RootLayout({ children }) { return ( + {/* Apply persisted theme before first paint so a reload does not flash the + default (light) theme before the client store hydrates. Mirrors the + zustand-persist "theme" key and the `dark` class applyTheme() sets. */} +