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"} + + ) : ( + {provider setImgError(true)} + /> + )} +
+ +
+

{name || provider}

+ {plan && ( + + {plan} + + )} +
+
+ + {/* Refresh Button */} + +
+ + {/* Loading State */} + {loading && ( +
+
+
+
+
+
+
+
+
+
+ )} + + {/* Error State */} + {!loading && error && ( +
+
+ + error + +

{error}

+
+
+ )} + + {/* Info Message (for providers without API) */} + {!loading && !error && message && ( +
+
+ + info + +

{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} Normalized quota objects with { name, used, total, resetAt } + */ +export function parseQuotaData(provider, data) { + if (!data || typeof data !== "object") return []; + + const normalizedQuotas = []; + + try { + switch (provider.toLowerCase()) { + case "github": + 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 "antigravity": + if (data.quotas) { + Object.entries(data.quotas).forEach(([modelName, quota]) => { + normalizedQuotas.push({ + name: modelName, + used: quota.used || 0, + total: quota.total || 0, + resetAt: quota.resetAt || null, + }); + }); + } + break; + + case "codex": + if (data.quotas) { + Object.entries(data.quotas).forEach(([quotaType, quota]) => { + normalizedQuotas.push({ + name: quotaType, + used: quota.used || 0, + total: quota.total || 0, + resetAt: quota.resetAt || null, + }); + }); + } + break; + + case "kiro": + if (data.quotas) { + Object.entries(data.quotas).forEach(([quotaType, quota]) => { + normalizedQuotas.push({ + name: quotaType, + used: quota.used || 0, + total: quota.total || 0, + resetAt: quota.resetAt || null, + }); + }); + } + break; + + case "claude": + if (data.message) { + // Handle error message case + normalizedQuotas.push({ + name: "error", + used: 0, + total: 0, + resetAt: null, + message: data.message, + }); + } else 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; + + default: + // Generic fallback for unknown providers + 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, + }); + }); + } + } + } catch (error) { + console.error(`Error parsing quota data for ${provider}:`, error); + return []; + } + + return normalizedQuotas; +} diff --git a/src/app/(dashboard)/dashboard/usage/page.js b/src/app/(dashboard)/dashboard/usage/page.js index 8408888d..c99ef5e8 100644 --- a/src/app/(dashboard)/dashboard/usage/page.js +++ b/src/app/(dashboard)/dashboard/usage/page.js @@ -2,6 +2,7 @@ import { useState, Suspense } from "react"; import { UsageStats, RequestLogger, CardSkeleton, SegmentedControl } from "@/shared/components"; +import ProviderLimits from "./components/ProviderLimits"; export default function UsagePage() { const [activeTab, setActiveTab] = useState("overview"); @@ -12,18 +13,23 @@ export default function UsagePage() { options={[ { value: "overview", label: "Overview" }, { value: "logs", label: "Logger" }, + // { value: "limits", label: "Limits" }, ]} value={activeTab} onChange={setActiveTab} /> {/* Content */} - {activeTab === "overview" ? ( + {activeTab === "overview" && ( }> - ) : ( - + )} + {activeTab === "logs" && } + {activeTab === "limits" && ( + }> + + )} );