diff --git a/open-sse/config/providerModels.js b/open-sse/config/providerModels.js
index 288cbd2c..4dd45ac3 100644
--- a/open-sse/config/providerModels.js
+++ b/open-sse/config/providerModels.js
@@ -27,6 +27,7 @@ export const PROVIDER_MODELS = {
{ id: "gemini-2.5-flash-lite", name: "Gemini 2.5 Flash Lite" },
],
qw: [ // Qwen Code
+ // { id: "qwen3-coder-next", name: "Qwen3 Coder Next" },
{ id: "qwen3-coder-plus", name: "Qwen3 Coder Plus" },
{ id: "qwen3-coder-flash", name: "Qwen3 Coder Flash" },
{ id: "vision-model", name: "Qwen3 Vision Model" },
@@ -38,7 +39,7 @@ export const PROVIDER_MODELS = {
{ id: "deepseek-r1", name: "DeepSeek R1" },
{ id: "deepseek-v3.2-chat", name: "DeepSeek V3.2 Chat" },
{ id: "deepseek-v3.2-reasoner", name: "DeepSeek V3.2 Reasoner" },
- { id: "minimax-m2", name: "MiniMax M2" },
+ { id: "minimax-m2.1", name: "MiniMax M2.1" },
{ id: "glm-4.7", name: "GLM 4.7" },
],
ag: [ // Antigravity - special case: models call different backends
@@ -59,7 +60,7 @@ export const PROVIDER_MODELS = {
{ id: "gpt-5.1-codex", name: "GPT-5.1 Codex" },
// { id: "gpt-5.1-codex-mini", name: "GPT-5.1 Codex Mini" },
{ id: "gpt-5.1-codex-max", name: "GPT-5.1 Codex Max" },
- { id: "gpt-4.1", name: "GPT-4.1" },
+ // { id: "gpt-4.1", name: "GPT-4.1" },
{ id: "claude-4.5-sonnet", name: "Claude 4.5 Sonnet" },
{ id: "claude-4.5-opus", name: "Claude 4.5 Opus" },
{ id: "claude-4.5-haiku", name: "Claude 4.5 Haiku" },
@@ -101,6 +102,8 @@ export const PROVIDER_MODELS = {
{ id: "glm-4.6v", name: "GLM 4.6V (Vision)" },
],
kimi: [
+ { id: "kimi-k2.5", name: "Kimi K2.5" },
+ { id: "kimi-k2.5-thinking", name: "Kimi K2.5 Thinking" },
{ id: "kimi-latest", name: "Kimi Latest" },
],
minimax: [
diff --git a/open-sse/handlers/chatCore.js b/open-sse/handlers/chatCore.js
index e50dda51..d880aa81 100644
--- a/open-sse/handlers/chatCore.js
+++ b/open-sse/handlers/chatCore.js
@@ -3,7 +3,7 @@ import { translateRequest, needsTranslation } from "../translator/index.js";
import { FORMATS } from "../translator/formats.js";
import { createSSETransformStreamWithLogger, createPassthroughStreamWithLogger, COLORS } from "../utils/stream.js";
import { createStreamController, pipeWithDisconnect } from "../utils/streamHandler.js";
-import { addBufferToUsage } from "../utils/usageTracking.js";
+import { addBufferToUsage, filterUsageForFormat } from "../utils/usageTracking.js";
import { refreshWithRetry } from "../services/tokenRefresh.js";
import { createRequestLogger } from "../utils/requestLogger.js";
import { getModelTargetFormat, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.js";
@@ -435,9 +435,10 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
? translateNonStreamingResponse(responseBody, targetFormat, sourceFormat)
: responseBody;
- // Add buffer to usage for client (to prevent CLI context errors)
+ // Add buffer and filter usage for client (to prevent CLI context errors)
if (translatedResponse?.usage) {
- translatedResponse.usage = addBufferToUsage(translatedResponse.usage);
+ const buffered = addBufferToUsage(translatedResponse.usage);
+ translatedResponse.usage = filterUsageForFormat(buffered, sourceFormat);
}
return {
diff --git a/open-sse/services/usage.js b/open-sse/services/usage.js
index 73ca3d77..301d993b 100644
--- a/open-sse/services/usage.js
+++ b/open-sse/services/usage.js
@@ -48,6 +48,8 @@ export async function getUsageForProvider(connection) {
return await getClaudeUsage(accessToken);
case "codex":
return await getCodexUsage(accessToken);
+ case "kiro":
+ return await getKiroUsage(accessToken, providerSpecificData);
case "qwen":
return await getQwenUsage(accessToken, providerSpecificData);
case "iflow":
@@ -367,6 +369,82 @@ async function getCodexUsage(accessToken) {
}
}
+/**
+ * Kiro (AWS CodeWhisperer) Usage
+ */
+async function getKiroUsage(accessToken, providerSpecificData) {
+ try {
+ const profileArn = providerSpecificData?.profileArn;
+ if (!profileArn) {
+ return { message: "Kiro connected. Profile ARN not available for quota tracking." };
+ }
+
+ // Kiro uses AWS CodeWhisperer GetUsageLimits API
+ const payload = {
+ origin: "AI_EDITOR",
+ profileArn: profileArn,
+ resourceType: "AGENTIC_REQUEST",
+ };
+
+ const response = await fetch("https://codewhisperer.us-east-1.amazonaws.com", {
+ method: "POST",
+ headers: {
+ "Authorization": `Bearer ${accessToken}`,
+ "Content-Type": "application/x-amz-json-1.0",
+ "x-amz-target": "AmazonCodeWhispererService.GetUsageLimits",
+ "Accept": "application/json",
+ },
+ body: JSON.stringify(payload),
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ throw new Error(`Kiro API error (${response.status}): ${errorText}`);
+ }
+
+ const data = await response.json();
+
+ // Parse usage data from usageBreakdownList
+ const usageList = data.usageBreakdownList || [];
+ const quotaInfo = {};
+
+ usageList.forEach((breakdown) => {
+ const resourceType = breakdown.resourceType?.toLowerCase() || "unknown";
+ const used = breakdown.currentUsageWithPrecision || 0;
+ const total = breakdown.usageLimitWithPrecision || 0;
+
+ quotaInfo[resourceType] = {
+ used,
+ total,
+ remaining: total - used,
+ resetTime: data.nextDateReset ? new Date(data.nextDateReset).toISOString() : null,
+ unlimited: false,
+ };
+
+ // Add free trial if available
+ if (breakdown.freeTrialInfo) {
+ const freeUsed = breakdown.freeTrialInfo.currentUsageWithPrecision || 0;
+ const freeTotal = breakdown.freeTrialInfo.usageLimitWithPrecision || 0;
+
+ quotaInfo[`${resourceType}_freetrial`] = {
+ used: freeUsed,
+ total: freeTotal,
+ remaining: freeTotal - freeUsed,
+ resetTime: data.nextDateReset ? new Date(data.nextDateReset).toISOString() : null,
+ unlimited: false,
+ };
+ }
+ });
+
+ return {
+ plan: data.subscriptionInfo?.subscriptionTitle || "Kiro",
+ quotas: quotaInfo,
+ };
+ } catch (error) {
+ throw new Error(`Failed to fetch Kiro usage: ${error.message}`);
+ }
+}
+
/**
* Qwen Usage
*/
diff --git a/open-sse/utils/stream.js b/open-sse/utils/stream.js
index d8fc1f9c..402b3733 100644
--- a/open-sse/utils/stream.js
+++ b/open-sse/utils/stream.js
@@ -1,7 +1,7 @@
import { translateResponse, initState } from "../translator/index.js";
import { FORMATS } from "../translator/formats.js";
import { trackPendingRequest, appendRequestLog } from "@/lib/usageDb.js";
-import { extractUsage, hasValidUsage, estimateUsage, logUsage, addBufferToUsage, COLORS } from "./usageTracking.js";
+import { extractUsage, hasValidUsage, estimateUsage, logUsage, addBufferToUsage, filterUsageForFormat, COLORS } from "./usageTracking.js";
// Re-export COLORS for backward compatibility
export { COLORS };
@@ -145,13 +145,14 @@ export function createSSEStream(options = {}) {
const isFinishChunk = parsed.choices?.[0]?.finish_reason;
if (isFinishChunk && !hasValidUsage(parsed.usage)) {
const estimated = estimateUsage(body, totalContentLength, FORMATS.OPENAI);
- parsed.usage = estimated; // Already has buffer from formatUsage
+ parsed.usage = filterUsageForFormat(estimated, FORMATS.OPENAI); // Filter + already has buffer
output = `data: ${JSON.stringify(parsed)}\n`;
usage = estimated;
injectedUsage = true;
} else if (isFinishChunk && usage) {
- // Add buffer to usage for client (but keep original for logging)
- parsed.usage = addBufferToUsage(usage);
+ // Add buffer and filter usage for client (but keep original for logging)
+ const buffered = addBufferToUsage(usage);
+ parsed.usage = filterUsageForFormat(buffered, FORMATS.OPENAI);
output = `data: ${JSON.stringify(parsed)}\n`;
injectedUsage = true;
}
@@ -234,11 +235,12 @@ export function createSSEStream(options = {}) {
const isFinishChunk = item.type === "message_delta" || item.choices?.[0]?.finish_reason;
if (state.finishReason && isFinishChunk && !hasValidUsage(item.usage) && totalContentLength > 0) {
const estimated = estimateUsage(body, totalContentLength, sourceFormat);
- item.usage = estimated; // Already has buffer from formatUsage
+ item.usage = filterUsageForFormat(estimated, sourceFormat); // Filter + already has buffer
state.usage = estimated;
} else if (state.finishReason && isFinishChunk && state.usage) {
- // Add buffer to usage for client (but keep original in state.usage for logging)
- item.usage = addBufferToUsage(state.usage);
+ // Add buffer and filter usage for client (but keep original in state.usage for logging)
+ const buffered = addBufferToUsage(state.usage);
+ item.usage = filterUsageForFormat(buffered, sourceFormat);
}
const output = formatSSE(item, sourceFormat);
diff --git a/open-sse/utils/usageTracking.js b/open-sse/utils/usageTracking.js
index 5326dece..b6646e24 100644
--- a/open-sse/utils/usageTracking.js
+++ b/open-sse/utils/usageTracking.js
@@ -43,14 +43,72 @@ export function addBufferToUsage(usage) {
result.prompt_tokens += BUFFER_TOKENS;
}
- // Update total_tokens if exists
+ // Calculate or update total_tokens
if (result.total_tokens !== undefined) {
result.total_tokens += BUFFER_TOKENS;
+ } else if (result.prompt_tokens !== undefined && result.completion_tokens !== undefined) {
+ // Calculate total_tokens if not exists
+ result.total_tokens = result.prompt_tokens + result.completion_tokens;
}
return result;
}
+export function filterUsageForFormat(usage, targetFormat) {
+ if (!usage || typeof usage !== "object") return usage;
+
+ // Helper to pick only defined fields from usage
+ const pickFields = (fields) => {
+ const filtered = {};
+ for (const field of fields) {
+ if (usage[field] !== undefined) {
+ filtered[field] = usage[field];
+ }
+ }
+ return filtered;
+ };
+
+ // Define allowed fields for each format
+ const formatFields = {
+ [FORMATS.CLAUDE]: [
+ 'input_tokens', 'output_tokens',
+ 'cache_read_input_tokens', 'cache_creation_input_tokens',
+ 'estimated'
+ ],
+ [FORMATS.GEMINI]: [
+ 'promptTokenCount', 'candidatesTokenCount', 'totalTokenCount',
+ 'cachedContentTokenCount', 'thoughtsTokenCount',
+ 'estimated'
+ ],
+ [FORMATS.OPENAI_RESPONSES]: [
+ 'input_tokens', 'output_tokens',
+ 'input_tokens_details', 'output_tokens_details',
+ 'estimated'
+ ],
+ // OpenAI format (default for OPENAI, CODEX, KIRO, etc.)
+ default: [
+ 'prompt_tokens', 'completion_tokens', 'total_tokens',
+ 'cached_tokens', 'reasoning_tokens',
+ 'prompt_tokens_details', 'completion_tokens_details',
+ 'estimated'
+ ]
+ };
+
+ // Get fields for target format
+ let fields = formatFields[targetFormat];
+
+ // Use same fields for similar formats
+ if (targetFormat === FORMATS.GEMINI_CLI || targetFormat === FORMATS.ANTIGRAVITY) {
+ fields = formatFields[FORMATS.GEMINI];
+ } else if (targetFormat === FORMATS.OPENAI_RESPONSE) {
+ fields = formatFields[FORMATS.OPENAI_RESPONSES];
+ } else if (!fields) {
+ fields = formatFields.default;
+ }
+
+ return pickFields(fields);
+}
+
/**
* Normalize usage object - ensure all values are valid numbers
*/
@@ -66,6 +124,7 @@ export function normalizeUsage(usage) {
assignNumber("prompt_tokens", usage?.prompt_tokens);
assignNumber("completion_tokens", usage?.completion_tokens);
+ assignNumber("total_tokens", usage?.total_tokens);
assignNumber("cache_read_input_tokens", usage?.cache_read_input_tokens);
assignNumber("cache_creation_input_tokens", usage?.cache_creation_input_tokens);
assignNumber("cached_tokens", usage?.cached_tokens);
@@ -141,6 +200,7 @@ export function extractUsage(chunk) {
return normalizeUsage({
prompt_tokens: chunk.usageMetadata?.promptTokenCount || 0,
completion_tokens: chunk.usageMetadata?.candidatesTokenCount || 0,
+ total_tokens: chunk.usageMetadata?.totalTokenCount,
cached_tokens: chunk.usageMetadata?.cachedContentTokenCount,
reasoning_tokens: chunk.usageMetadata?.thoughtsTokenCount
});
diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/ProviderLimitCard.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/ProviderLimitCard.js
new file mode 100644
index 00000000..31b3a5e9
--- /dev/null
+++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/ProviderLimitCard.js
@@ -0,0 +1,187 @@
+"use client";
+
+import { useState } from "react";
+import Image from "next/image";
+import Card from "@/shared/components/Card";
+import Badge from "@/shared/components/Badge";
+import QuotaProgressBar from "./QuotaProgressBar";
+import { calculatePercentage } from "./utils";
+
+const planVariants = {
+ free: "default",
+ pro: "primary",
+ ultra: "success",
+ enterprise: "info",
+};
+
+export default function ProviderLimitCard({
+ provider,
+ name,
+ plan,
+ quotas = [],
+ message = null,
+ loading = false,
+ error = null,
+ onRefresh,
+}) {
+ const [refreshing, setRefreshing] = useState(false);
+ const [imgError, setImgError] = useState(false);
+
+ const handleRefresh = async () => {
+ if (!onRefresh || refreshing) return;
+
+ setRefreshing(true);
+ try {
+ await onRefresh();
+ } finally {
+ setRefreshing(false);
+ }
+ };
+
+ // Get provider info from config
+ const getProviderColor = () => {
+ const colors = {
+ github: "#000000",
+ antigravity: "#4285F4",
+ codex: "#10A37F",
+ kiro: "#FF9900",
+ claude: "#D97757",
+ };
+ return colors[provider?.toLowerCase()] || "#6B7280";
+ };
+
+ const providerColor = getProviderColor();
+ const planVariant = planVariants[plan?.toLowerCase()] || "default";
+
+ return (
+
+ {/* Header */}
+
+
+ {/* Provider Logo */}
+
+ {imgError ? (
+
+ {provider?.slice(0, 2).toUpperCase() || "PR"}
+
+ ) : (
+ setImgError(true)}
+ />
+ )}
+
+
+
+
{name || provider}
+ {plan && (
+
+ {plan}
+
+ )}
+
+
+
+ {/* Refresh Button */}
+
+
+
+ {/* Loading State */}
+ {loading && (
+
+ )}
+
+ {/* Error State */}
+ {!loading && error && (
+
+ )}
+
+ {/* Info Message (for providers without API) */}
+ {!loading && !error && message && (
+
+ )}
+
+ {/* Quota Progress Bars */}
+ {!loading && !error && !message && quotas?.length > 0 && (
+
+ {quotas.map((quota, index) => {
+ const percentage = calculatePercentage(quota.used, quota.total);
+ const unlimited = quota.total === 0 || quota.total === null;
+
+ return (
+
+ );
+ })}
+
+ )}
+
+ {/* Empty State */}
+ {!loading && !error && !message && quotas?.length === 0 && (
+
+
+ data_usage
+
+
No quota data available
+
+ )}
+
+ );
+}
diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaProgressBar.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaProgressBar.js
new file mode 100644
index 00000000..2dd7b0d0
--- /dev/null
+++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaProgressBar.js
@@ -0,0 +1,105 @@
+"use client";
+
+import { cn } from "@/shared/utils/cn";
+
+// Helper function to calculate time until reset
+const getResetTimeText = (resetTime) => {
+ if (!resetTime) return null;
+
+ const now = new Date();
+ const reset = new Date(resetTime);
+ const diffMs = reset - now;
+
+ if (diffMs <= 0) return "Reset now";
+
+ const hours = Math.floor(diffMs / (1000 * 60 * 60));
+ const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
+
+ if (hours > 0) {
+ return `Reset in ${hours}h`;
+ }
+ return `Reset in ${minutes}m`;
+};
+
+// Calculate color based on remaining percentage
+const getColorClasses = (percentage) => {
+ if (percentage === 0) {
+ return {
+ text: "text-gray-400",
+ bg: "bg-gray-400",
+ bgLight: "bg-gray-400/10"
+ };
+ }
+
+ const remaining = 100 - percentage;
+
+ if (remaining > 70) {
+ return {
+ text: "text-green-500",
+ bg: "bg-green-500",
+ bgLight: "bg-green-500/10"
+ };
+ }
+
+ if (remaining >= 30) {
+ return {
+ text: "text-yellow-500",
+ bg: "bg-yellow-500",
+ bgLight: "bg-yellow-500/10"
+ };
+ }
+
+ return {
+ text: "text-red-500",
+ bg: "bg-red-500",
+ bgLight: "bg-red-500/10"
+ };
+};
+
+export default function QuotaProgressBar({
+ percentage = 0,
+ label = "",
+ used = 0,
+ total = 0,
+ unlimited = false,
+ resetTime = null
+}) {
+ const colors = getColorClasses(percentage);
+ const resetText = getResetTimeText(resetTime);
+
+ return (
+
+ {/* Label and usage info */}
+
+
+ {label}
+
+
+ {unlimited ? (
+ Unlimited
+ ) : (
+
+ {used.toLocaleString()}/{total.toLocaleString()} ({percentage}%)
+
+ )}
+ {resetText && (
+ <>
+ •
+ {resetText}
+ >
+ )}
+
+
+
+ {/* Progress bar */}
+ {!unlimited && (
+
+ )}
+
+ );
+}
diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js
new file mode 100644
index 00000000..efcd8d52
--- /dev/null
+++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js
@@ -0,0 +1,328 @@
+"use client";
+
+import { useState, useEffect, useCallback, useRef } from "react";
+import ProviderLimitCard from "./ProviderLimitCard";
+import { parseQuotaData, calculatePercentage } from "./utils";
+import Card from "@/shared/components/Card";
+import Button from "@/shared/components/Button";
+import { CardSkeleton } from "@/shared/components/Loading";
+
+const REFRESH_INTERVAL_MS = 60000; // 60 seconds
+
+export default function ProviderLimits() {
+ const [connections, setConnections] = useState([]);
+ const [quotaData, setQuotaData] = useState({});
+ const [loading, setLoading] = useState({});
+ const [errors, setErrors] = useState({});
+ const [autoRefresh, setAutoRefresh] = useState(true);
+ const [lastUpdated, setLastUpdated] = useState(null);
+ const [refreshingAll, setRefreshingAll] = useState(false);
+ const [countdown, setCountdown] = useState(60);
+ const [initialLoading, setInitialLoading] = useState(true);
+
+ const intervalRef = useRef(null);
+ const countdownRef = useRef(null);
+
+ // Fetch all provider connections
+ const fetchConnections = useCallback(async () => {
+ try {
+ const response = await fetch("/api/providers/client");
+ if (!response.ok) throw new Error("Failed to fetch connections");
+
+ const data = await response.json();
+ const connectionList = data.connections || [];
+ setConnections(connectionList);
+ return connectionList;
+ } catch (error) {
+ console.error("Error fetching connections:", error);
+ setConnections([]);
+ return [];
+ }
+ }, []);
+
+ // Fetch quota for a specific connection
+ const fetchQuota = useCallback(async (connectionId, provider) => {
+ setLoading((prev) => ({ ...prev, [connectionId]: true }));
+ setErrors((prev) => ({ ...prev, [connectionId]: null }));
+
+ try {
+ console.log(`[ProviderLimits] Fetching quota for ${provider} (${connectionId})`);
+ const response = await fetch(`/api/usage/${connectionId}`);
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
+ }
+
+ const data = await response.json();
+ console.log(`[ProviderLimits] Got quota for ${provider}:`, data);
+
+ // Parse quota data using provider-specific parser
+ const parsedQuotas = parseQuotaData(provider, data);
+
+ setQuotaData((prev) => ({
+ ...prev,
+ [connectionId]: {
+ quotas: parsedQuotas,
+ plan: data.plan || null,
+ message: data.message || null,
+ raw: data,
+ },
+ }));
+ } catch (error) {
+ console.error(`[ProviderLimits] Error fetching quota for ${provider} (${connectionId}):`, error);
+ setErrors((prev) => ({
+ ...prev,
+ [connectionId]: error.message || "Failed to fetch quota",
+ }));
+ } finally {
+ setLoading((prev) => ({ ...prev, [connectionId]: false }));
+ }
+ }, []);
+
+ // Refresh quota for a specific provider
+ const refreshProvider = useCallback(
+ async (connectionId, provider) => {
+ await fetchQuota(connectionId, provider);
+ setLastUpdated(new Date());
+ },
+ [fetchQuota]
+ );
+
+ // Refresh all providers
+ const refreshAll = useCallback(async () => {
+ if (refreshingAll) return;
+
+ setRefreshingAll(true);
+ setCountdown(60);
+
+ try {
+ const conns = await fetchConnections();
+
+ // Fetch quota for all connections (filter by provider support in parseQuotaData)
+ await Promise.all(
+ conns.map((conn) => fetchQuota(conn.id, conn.provider))
+ );
+
+ setLastUpdated(new Date());
+ } catch (error) {
+ console.error("Error refreshing all providers:", error);
+ } finally {
+ setRefreshingAll(false);
+ }
+ }, [refreshingAll, fetchConnections, fetchQuota]);
+
+ // Initial load
+ useEffect(() => {
+ const initializeData = async () => {
+ setInitialLoading(true);
+ await refreshAll();
+ setInitialLoading(false);
+ };
+
+ initializeData();
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
+
+ // Auto-refresh interval
+ useEffect(() => {
+ if (!autoRefresh) {
+ if (intervalRef.current) {
+ clearInterval(intervalRef.current);
+ intervalRef.current = null;
+ }
+ if (countdownRef.current) {
+ clearInterval(countdownRef.current);
+ countdownRef.current = null;
+ }
+ return;
+ }
+
+ // Main refresh interval
+ intervalRef.current = setInterval(() => {
+ refreshAll();
+ }, REFRESH_INTERVAL_MS);
+
+ // Countdown interval
+ countdownRef.current = setInterval(() => {
+ setCountdown((prev) => {
+ if (prev <= 1) return 60;
+ return prev - 1;
+ });
+ }, 1000);
+
+ return () => {
+ if (intervalRef.current) clearInterval(intervalRef.current);
+ if (countdownRef.current) clearInterval(countdownRef.current);
+ };
+ }, [autoRefresh, refreshAll]);
+
+ // Pause auto-refresh when tab is hidden (Page Visibility API)
+ useEffect(() => {
+ const handleVisibilityChange = () => {
+ if (document.hidden) {
+ if (intervalRef.current) {
+ clearInterval(intervalRef.current);
+ intervalRef.current = null;
+ }
+ if (countdownRef.current) {
+ clearInterval(countdownRef.current);
+ countdownRef.current = null;
+ }
+ } else if (autoRefresh) {
+ // Resume auto-refresh when tab becomes visible
+ intervalRef.current = setInterval(refreshAll, REFRESH_INTERVAL_MS);
+ countdownRef.current = setInterval(() => {
+ setCountdown((prev) => (prev <= 1 ? 60 : prev - 1));
+ }, 1000);
+ }
+ };
+
+ document.addEventListener("visibilitychange", handleVisibilityChange);
+ return () => {
+ document.removeEventListener("visibilitychange", handleVisibilityChange);
+ };
+ }, [autoRefresh, refreshAll]);
+
+ // Format last updated time
+ const formatLastUpdated = useCallback(() => {
+ if (!lastUpdated) return "Never";
+
+ const now = new Date();
+ const diffMs = now - lastUpdated;
+ const diffMinutes = Math.floor(diffMs / (1000 * 60));
+ const diffHours = Math.floor(diffMinutes / 60);
+ const diffDays = Math.floor(diffHours / 24);
+
+ if (diffDays > 0) return `${diffDays}d ago`;
+ if (diffHours > 0) return `${diffHours}h ago`;
+ if (diffMinutes > 0) return `${diffMinutes}m ago`;
+ return "Just now";
+ }, [lastUpdated]);
+
+ // Filter only supported providers
+ const supportedProviders = ["antigravity", "kiro", "github", "claude"];
+ const filteredConnections = connections.filter((conn) =>
+ supportedProviders.includes(conn.provider)
+ );
+
+ // Calculate summary stats
+ const totalProviders = filteredConnections.length;
+ const activeWithLimits = Object.values(quotaData).filter(
+ (data) => data?.quotas?.length > 0
+ ).length;
+
+ // Count low quotas (remaining < 30%)
+ const lowQuotasCount = Object.values(quotaData).reduce((count, data) => {
+ if (!data?.quotas) return count;
+
+ const hasLowQuota = data.quotas.some((quota) => {
+ const percentage = calculatePercentage(quota.used, quota.total);
+ return percentage < 30 && quota.total > 0;
+ });
+
+ return count + (hasLowQuota ? 1 : 0);
+ }, 0);
+
+ // Initial loading state
+ if (initialLoading) {
+ return (
+
+ );
+ }
+
+ // Empty state
+ if (filteredConnections.length === 0) {
+ return (
+
+
+
+ cloud_off
+
+
+ No Providers Connected
+
+
+ Connect to providers with OAuth to track your API quota limits and usage.
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Header Controls */}
+
+
+
+ Provider Limits
+
+
+ Last updated: {formatLastUpdated()}
+
+
+
+
+ {/* Auto-refresh toggle */}
+
+
+ {/* Refresh all button */}
+
+
+
+
+ {/* Provider Cards Grid */}
+
+ {filteredConnections.map((conn) => {
+ const quota = quotaData[conn.id];
+ const isLoading = loading[conn.id];
+ const error = errors[conn.id];
+
+ return (
+
refreshProvider(conn.id, conn.provider)}
+ />
+ );
+ })}
+
+
+ );
+}
diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js
new file mode 100644
index 00000000..b30a9d6e
--- /dev/null
+++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js
@@ -0,0 +1,188 @@
+/**
+ * Format ISO date string to countdown format
+ * @param {string|Date} date - ISO date string or Date object
+ * @returns {string} Formatted countdown (e.g., "5d 12h", "2h 30m", "15m") or "-"
+ */
+export function formatResetTime(date) {
+ if (!date) return "-";
+
+ try {
+ const resetDate = typeof date === "string" ? new Date(date) : date;
+ const now = new Date();
+ const diffMs = resetDate - now;
+
+ if (diffMs <= 0) return "-";
+
+ const totalMinutes = Math.floor(diffMs / (1000 * 60));
+ const totalHours = Math.floor(totalMinutes / 60);
+ const totalDays = Math.floor(totalHours / 24);
+
+ const days = totalDays;
+ const hours = totalHours % 24;
+ const minutes = totalMinutes % 60;
+
+ if (days > 0) {
+ return `${days}d ${hours}h`;
+ }
+
+ if (hours > 0) {
+ return `${hours}h ${minutes}m`;
+ }
+
+ return `${minutes}m`;
+ } catch (error) {
+ return "-";
+ }
+}
+
+/**
+ * Get Tailwind color class based on percentage
+ * @param {number} percentage - Remaining percentage (0-100)
+ * @returns {string} Color name: "green" | "yellow" | "red" | "gray"
+ */
+export function getStatusColor(percentage) {
+ if (percentage === 0 || percentage === null || percentage === undefined) {
+ return "gray";
+ }
+
+ if (percentage > 70) return "green";
+ if (percentage >= 30) return "yellow";
+ return "red";
+}
+
+/**
+ * Get status emoji based on percentage
+ * @param {number} percentage - Remaining percentage (0-100)
+ * @returns {string} Emoji: "🟢" | "🟡" | "🔴" | "⚫"
+ */
+export function getStatusEmoji(percentage) {
+ if (percentage === 0 || percentage === null || percentage === undefined) {
+ return "⚫";
+ }
+
+ if (percentage > 70) return "🟢";
+ if (percentage >= 30) return "🟡";
+ return "🔴";
+}
+
+/**
+ * Calculate remaining percentage
+ * @param {number} used - Used amount
+ * @param {number} total - Total amount
+ * @returns {number} Remaining percentage (0-100)
+ */
+export function calculatePercentage(used, total) {
+ if (!total || total === 0) return 0;
+ if (!used || used < 0) return 100;
+ if (used >= total) return 0;
+
+ return Math.round(((total - used) / total) * 100);
+}
+
+/**
+ * Parse provider-specific quota structures into normalized array
+ * @param {string} provider - Provider name (github, antigravity, codex, kiro, claude)
+ * @param {Object} data - Raw quota data from provider
+ * @returns {Array