mirror of
https://github.com/decolua/9router.git
synced 2026-05-08 12:01:28 +00:00
Fix : usage convert
This commit is contained in:
@@ -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: [
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
<Card padding="md" className="flex flex-col gap-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Provider Logo */}
|
||||
<div
|
||||
className="size-10 rounded-lg flex items-center justify-center p-1.5"
|
||||
style={{ backgroundColor: `${providerColor}15` }}
|
||||
>
|
||||
{imgError ? (
|
||||
<span
|
||||
className="text-sm font-bold"
|
||||
style={{ color: providerColor }}
|
||||
>
|
||||
{provider?.slice(0, 2).toUpperCase() || "PR"}
|
||||
</span>
|
||||
) : (
|
||||
<Image
|
||||
src={`/providers/${provider}.png`}
|
||||
alt={provider || "Provider"}
|
||||
width={40}
|
||||
height={40}
|
||||
className="object-contain rounded-lg"
|
||||
sizes="40px"
|
||||
onError={() => setImgError(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold text-text-primary">{name || provider}</h3>
|
||||
{plan && (
|
||||
<Badge
|
||||
variant={planVariants[plan?.toLowerCase()] || "default"}
|
||||
size="xs"
|
||||
>
|
||||
{plan}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Refresh Button */}
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing || loading}
|
||||
className="p-2 rounded-lg hover:bg-black/5 dark:hover:bg-white/5 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Refresh quota"
|
||||
>
|
||||
<span
|
||||
className={`material-symbols-outlined text-[20px] text-text-muted ${
|
||||
refreshing || loading ? "animate-spin" : ""
|
||||
}`}
|
||||
>
|
||||
refresh
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Loading State */}
|
||||
{loading && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 bg-black/5 dark:bg-white/5 rounded animate-pulse" />
|
||||
<div className="h-2 bg-black/5 dark:bg-white/5 rounded animate-pulse" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 bg-black/5 dark:bg-white/5 rounded animate-pulse" />
|
||||
<div className="h-2 bg-black/5 dark:bg-white/5 rounded animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error State */}
|
||||
{!loading && error && (
|
||||
<div className="p-4 rounded-lg bg-red-500/10 border border-red-500/20">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="material-symbols-outlined text-red-500 text-[20px]">
|
||||
error
|
||||
</span>
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info Message (for providers without API) */}
|
||||
{!loading && !error && message && (
|
||||
<div className="p-4 rounded-lg bg-blue-500/10 border border-blue-500/20">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="material-symbols-outlined text-blue-500 text-[20px]">
|
||||
info
|
||||
</span>
|
||||
<p className="text-sm text-blue-600 dark:text-blue-400">{message}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quota Progress Bars */}
|
||||
{!loading && !error && !message && quotas?.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{quotas.map((quota, index) => {
|
||||
const percentage = calculatePercentage(quota.used, quota.total);
|
||||
const unlimited = quota.total === 0 || quota.total === null;
|
||||
|
||||
return (
|
||||
<QuotaProgressBar
|
||||
key={`${quota.name}-${index}`}
|
||||
label={quota.name}
|
||||
used={quota.used}
|
||||
total={quota.total}
|
||||
percentage={percentage}
|
||||
unlimited={unlimited}
|
||||
resetTime={quota.resetAt}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!loading && !error && !message && quotas?.length === 0 && (
|
||||
<div className="text-center py-8 text-text-muted">
|
||||
<span className="material-symbols-outlined text-[48px] opacity-20">
|
||||
data_usage
|
||||
</span>
|
||||
<p className="text-sm mt-2">No quota data available</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-2">
|
||||
{/* Label and usage info */}
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="font-medium text-text-primary dark:text-white">
|
||||
{label}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 text-text-muted">
|
||||
{unlimited ? (
|
||||
<span>Unlimited</span>
|
||||
) : (
|
||||
<span>
|
||||
{used.toLocaleString()}/{total.toLocaleString()} ({percentage}%)
|
||||
</span>
|
||||
)}
|
||||
{resetText && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span className="text-xs">{resetText}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
{!unlimited && (
|
||||
<div className={cn("h-2 rounded-full overflow-hidden", colors.bgLight)}>
|
||||
<div
|
||||
className={cn("h-full transition-all duration-300", colors.bg)}
|
||||
style={{ width: `${Math.min(percentage, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-4">
|
||||
<CardSkeleton />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Empty state
|
||||
if (filteredConnections.length === 0) {
|
||||
return (
|
||||
<Card padding="lg">
|
||||
<div className="text-center py-12">
|
||||
<span className="material-symbols-outlined text-[64px] text-text-muted opacity-20">
|
||||
cloud_off
|
||||
</span>
|
||||
<h3 className="mt-4 text-lg font-semibold text-text-primary">
|
||||
No Providers Connected
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-text-muted max-w-md mx-auto">
|
||||
Connect to providers with OAuth to track your API quota limits and usage.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header Controls */}
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-xl font-semibold text-text-primary">
|
||||
Provider Limits
|
||||
</h2>
|
||||
<span className="text-sm text-text-muted">
|
||||
Last updated: {formatLastUpdated()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Auto-refresh toggle */}
|
||||
<button
|
||||
onClick={() => setAutoRefresh((prev) => !prev)}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-lg border border-black/10 dark:border-white/10 hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
|
||||
title={autoRefresh ? "Disable auto-refresh" : "Enable auto-refresh"}
|
||||
>
|
||||
<span
|
||||
className={`material-symbols-outlined text-[18px] ${
|
||||
autoRefresh ? "text-primary" : "text-text-muted"
|
||||
}`}
|
||||
>
|
||||
{autoRefresh ? "toggle_on" : "toggle_off"}
|
||||
</span>
|
||||
<span className="text-sm text-text-primary">Auto-refresh</span>
|
||||
{autoRefresh && (
|
||||
<span className="text-xs text-text-muted">({countdown}s)</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Refresh all button */}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="md"
|
||||
icon="refresh"
|
||||
onClick={refreshAll}
|
||||
disabled={refreshingAll}
|
||||
loading={refreshingAll}
|
||||
>
|
||||
Refresh All
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Provider Cards Grid */}
|
||||
<div className="flex flex-col gap-4">
|
||||
{filteredConnections.map((conn) => {
|
||||
const quota = quotaData[conn.id];
|
||||
const isLoading = loading[conn.id];
|
||||
const error = errors[conn.id];
|
||||
|
||||
return (
|
||||
<ProviderLimitCard
|
||||
key={conn.id}
|
||||
provider={conn.provider}
|
||||
name={conn.name}
|
||||
plan={quota?.plan}
|
||||
quotas={quota?.quotas}
|
||||
message={quota?.message}
|
||||
loading={isLoading}
|
||||
error={error}
|
||||
onRefresh={() => refreshProvider(conn.id, conn.provider)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<Object>} 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;
|
||||
}
|
||||
@@ -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" && (
|
||||
<Suspense fallback={<CardSkeleton />}>
|
||||
<UsageStats />
|
||||
</Suspense>
|
||||
) : (
|
||||
<RequestLogger />
|
||||
)}
|
||||
{activeTab === "logs" && <RequestLogger />}
|
||||
{activeTab === "limits" && (
|
||||
<Suspense fallback={<CardSkeleton />}>
|
||||
<ProviderLimits />
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user