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. */}
+