mirror of https://github.com/kortix-ai/suna.git
fix(billing): implement usage logs retrieval and display in frontend with pagination support
This commit is contained in:
parent
235aec6d1e
commit
87908cd526
|
@ -225,74 +225,220 @@ async def get_user_subscription(user_id: str) -> Optional[Dict]:
|
|||
|
||||
async def calculate_monthly_usage(client, user_id: str) -> float:
|
||||
"""Calculate total agent run minutes for the current month for a user."""
|
||||
start_time = time.time()
|
||||
|
||||
# Use get_usage_logs to fetch all usage data (it already handles the date filtering and batching)
|
||||
total_cost = 0.0
|
||||
page = 0
|
||||
items_per_page = 1000
|
||||
|
||||
while True:
|
||||
# Get usage logs for this page
|
||||
usage_result = await get_usage_logs(client, user_id, page, items_per_page)
|
||||
|
||||
if not usage_result['logs']:
|
||||
break
|
||||
|
||||
# Sum up the estimated costs from this page
|
||||
for log_entry in usage_result['logs']:
|
||||
total_cost += log_entry['estimated_cost']
|
||||
|
||||
# If there are no more pages, break
|
||||
if not usage_result['has_more']:
|
||||
break
|
||||
|
||||
page += 1
|
||||
|
||||
end_time = time.time()
|
||||
execution_time = end_time - start_time
|
||||
logger.info(f"Calculate monthly usage took {execution_time:.3f} seconds, total cost: {total_cost}")
|
||||
|
||||
return total_cost
|
||||
|
||||
|
||||
async def get_usage_logs(client, user_id: str, page: int = 0, items_per_page: int = 1000) -> Dict:
|
||||
"""Get detailed usage logs for a user with pagination."""
|
||||
# Get start of current month in UTC
|
||||
now = datetime.now(timezone.utc)
|
||||
start_of_month = datetime(now.year, now.month, 1, tzinfo=timezone.utc)
|
||||
|
||||
# Use fixed cutoff date: June 27, 2025 midnight UTC
|
||||
# Use fixed cutoff date: June 26, 2025 midnight UTC
|
||||
# Ignore all token counts before this date
|
||||
cutoff_date = datetime(2025, 6, 27, 0, 0, 0, tzinfo=timezone.utc)
|
||||
cutoff_date = datetime(2025, 6, 26, 0, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
# Use the later of the two dates (start of month or cutoff date)
|
||||
start_of_month = max(start_of_month, cutoff_date)
|
||||
|
||||
# First get all threads for this user
|
||||
threads_result = await client.table('threads') \
|
||||
.select('thread_id') \
|
||||
.eq('account_id', user_id) \
|
||||
.execute()
|
||||
# First get all threads for this user in batches
|
||||
batch_size = 1000
|
||||
offset = 0
|
||||
all_threads = []
|
||||
|
||||
if not threads_result.data:
|
||||
return 0.0
|
||||
while True:
|
||||
threads_batch = await client.table('threads') \
|
||||
.select('thread_id') \
|
||||
.eq('account_id', user_id) \
|
||||
.gte('created_at', start_of_month.isoformat()) \
|
||||
.range(offset, offset + batch_size - 1) \
|
||||
.execute()
|
||||
|
||||
if not threads_batch.data:
|
||||
break
|
||||
|
||||
all_threads.extend(threads_batch.data)
|
||||
|
||||
# If we got less than batch_size, we've reached the end
|
||||
if len(threads_batch.data) < batch_size:
|
||||
break
|
||||
|
||||
offset += batch_size
|
||||
|
||||
thread_ids = [t['thread_id'] for t in threads_result.data]
|
||||
|
||||
if not all_threads:
|
||||
return {"logs": [], "has_more": False}
|
||||
|
||||
thread_ids = [t['thread_id'] for t in all_threads]
|
||||
|
||||
# Fetch usage messages with pagination, including thread project info
|
||||
start_time = time.time()
|
||||
token_messages = await client.table('messages') \
|
||||
.select('content') \
|
||||
messages_result = await client.table('messages') \
|
||||
.select(
|
||||
'message_id, thread_id, created_at, content, threads!inner(project_id)'
|
||||
) \
|
||||
.in_('thread_id', thread_ids) \
|
||||
.gte('created_at', start_of_month.isoformat()) \
|
||||
.eq('type', 'assistant_response_end') \
|
||||
.gte('created_at', start_of_month.isoformat()) \
|
||||
.order('created_at', desc=True) \
|
||||
.range(page * items_per_page, (page + 1) * items_per_page - 1) \
|
||||
.execute()
|
||||
|
||||
end_time = time.time()
|
||||
execution_time = end_time - start_time
|
||||
logger.info(f"Database query for token messages took {execution_time:.3f} seconds")
|
||||
logger.info(f"Database query for usage logs took {execution_time:.3f} seconds")
|
||||
|
||||
if not token_messages.data:
|
||||
return 0.0
|
||||
if not messages_result.data:
|
||||
return {"logs": [], "has_more": False}
|
||||
|
||||
# Calculate total cost per message (to handle different models correctly)
|
||||
total_cost = 0.0
|
||||
# Process messages into usage log entries
|
||||
processed_logs = []
|
||||
|
||||
for run in token_messages.data:
|
||||
prompt_tokens = run['content']['usage']['prompt_tokens']
|
||||
completion_tokens = run['content']['usage']['completion_tokens']
|
||||
model = run['content']['model']
|
||||
for message in messages_result.data:
|
||||
try:
|
||||
# Safely extract usage data with defaults
|
||||
content = message.get('content', {})
|
||||
usage = content.get('usage', {})
|
||||
|
||||
# Ensure usage has required fields with safe defaults
|
||||
prompt_tokens = usage.get('prompt_tokens', 0)
|
||||
completion_tokens = usage.get('completion_tokens', 0)
|
||||
model = content.get('model', 'unknown')
|
||||
|
||||
# Safely calculate total tokens
|
||||
total_tokens = (prompt_tokens or 0) + (completion_tokens or 0)
|
||||
|
||||
# Calculate estimated cost using the same logic as calculate_monthly_usage
|
||||
estimated_cost = calculate_token_cost(
|
||||
prompt_tokens,
|
||||
completion_tokens,
|
||||
model
|
||||
)
|
||||
|
||||
# Safely extract project_id from threads relationship
|
||||
project_id = 'unknown'
|
||||
if message.get('threads') and isinstance(message['threads'], list) and len(message['threads']) > 0:
|
||||
project_id = message['threads'][0].get('project_id', 'unknown')
|
||||
|
||||
processed_logs.append({
|
||||
'message_id': message.get('message_id', 'unknown'),
|
||||
'thread_id': message.get('thread_id', 'unknown'),
|
||||
'created_at': message.get('created_at', None),
|
||||
'content': {
|
||||
'usage': {
|
||||
'prompt_tokens': prompt_tokens,
|
||||
'completion_tokens': completion_tokens
|
||||
},
|
||||
'model': model
|
||||
},
|
||||
'total_tokens': total_tokens,
|
||||
'estimated_cost': estimated_cost,
|
||||
'project_id': project_id
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning(f"Error processing usage log entry for message {message.get('message_id', 'unknown')}: {str(e)}")
|
||||
continue
|
||||
|
||||
# Check if there are more results
|
||||
has_more = len(processed_logs) == items_per_page
|
||||
|
||||
return {
|
||||
"logs": processed_logs,
|
||||
"has_more": has_more
|
||||
}
|
||||
|
||||
# Check if we have hardcoded pricing for this model
|
||||
hardcoded_pricing = get_model_pricing(model)
|
||||
|
||||
def calculate_token_cost(prompt_tokens: int, completion_tokens: int, model: str) -> float:
|
||||
"""Calculate the cost for tokens using the same logic as the monthly usage calculation."""
|
||||
try:
|
||||
# Ensure tokens are valid integers
|
||||
prompt_tokens = int(prompt_tokens) if prompt_tokens is not None else 0
|
||||
completion_tokens = int(completion_tokens) if completion_tokens is not None else 0
|
||||
|
||||
# Try to resolve the model name using MODEL_NAME_ALIASES first
|
||||
resolved_model = MODEL_NAME_ALIASES.get(model, model)
|
||||
|
||||
# Check if we have hardcoded pricing for this model (try both original and resolved)
|
||||
hardcoded_pricing = get_model_pricing(model) or get_model_pricing(resolved_model)
|
||||
if hardcoded_pricing:
|
||||
input_cost_per_million, output_cost_per_million = hardcoded_pricing
|
||||
input_cost = (prompt_tokens / 1_000_000) * input_cost_per_million
|
||||
output_cost = (completion_tokens / 1_000_000) * output_cost_per_million
|
||||
message_cost = input_cost + output_cost
|
||||
else:
|
||||
# Use litellm pricing as fallback
|
||||
# Use litellm pricing as fallback - try multiple variations
|
||||
try:
|
||||
prompt_token_cost, completion_token_cost = cost_per_token(model, int(prompt_tokens), int(completion_tokens))
|
||||
message_cost = prompt_token_cost + completion_token_cost
|
||||
models_to_try = [model]
|
||||
|
||||
# Add resolved model if different
|
||||
if resolved_model != model:
|
||||
models_to_try.append(resolved_model)
|
||||
|
||||
# Try without provider prefix if it has one
|
||||
if '/' in model:
|
||||
models_to_try.append(model.split('/', 1)[1])
|
||||
if '/' in resolved_model and resolved_model != model:
|
||||
models_to_try.append(resolved_model.split('/', 1)[1])
|
||||
|
||||
# Special handling for Google models accessed via OpenRouter
|
||||
if model.startswith('openrouter/google/'):
|
||||
google_model_name = model.replace('openrouter/', '')
|
||||
models_to_try.append(google_model_name)
|
||||
if resolved_model.startswith('openrouter/google/'):
|
||||
google_model_name = resolved_model.replace('openrouter/', '')
|
||||
models_to_try.append(google_model_name)
|
||||
|
||||
# Try each model name variation until we find one that works
|
||||
message_cost = None
|
||||
for model_name in models_to_try:
|
||||
try:
|
||||
prompt_token_cost, completion_token_cost = cost_per_token(model_name, prompt_tokens, completion_tokens)
|
||||
if prompt_token_cost is not None and completion_token_cost is not None:
|
||||
message_cost = prompt_token_cost + completion_token_cost
|
||||
break
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to get pricing for model variation {model_name}: {str(e)}")
|
||||
continue
|
||||
|
||||
if message_cost is None:
|
||||
logger.warning(f"Could not get pricing for model {model} (resolved: {resolved_model}), returning 0 cost")
|
||||
return 0.0
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not get pricing for model {model}: {str(e)}, skipping message")
|
||||
continue
|
||||
logger.warning(f"Could not get pricing for model {model} (resolved: {resolved_model}): {str(e)}, returning 0 cost")
|
||||
return 0.0
|
||||
|
||||
total_cost += message_cost
|
||||
|
||||
# Return total cost * TOKEN_PRICE_MULTIPLIER (as per original logic)
|
||||
total_cost = total_cost * TOKEN_PRICE_MULTIPLIER
|
||||
logger.info(f"Total cost for user {user_id}: {total_cost}")
|
||||
|
||||
return total_cost
|
||||
# Apply the TOKEN_PRICE_MULTIPLIER
|
||||
return message_cost * TOKEN_PRICE_MULTIPLIER
|
||||
except Exception as e:
|
||||
logger.error(f"Error calculating token cost for model {model}: {str(e)}")
|
||||
return 0.0
|
||||
|
||||
async def get_allowed_models_for_user(client, user_id: str):
|
||||
"""
|
||||
|
@ -797,7 +943,7 @@ async def get_subscription(
|
|||
price_id=free_tier_id,
|
||||
minutes_limit=free_tier_info.get('minutes') if free_tier_info else 0,
|
||||
cost_limit=free_tier_info.get('cost') if free_tier_info else 0,
|
||||
current_usage=round(current_usage, 2)
|
||||
current_usage=current_usage
|
||||
)
|
||||
|
||||
# Extract current plan details
|
||||
|
@ -818,7 +964,7 @@ async def get_subscription(
|
|||
trial_end=datetime.fromtimestamp(subscription['trial_end'], tz=timezone.utc) if subscription.get('trial_end') else None,
|
||||
minutes_limit=current_tier_info['minutes'],
|
||||
cost_limit=current_tier_info['cost'],
|
||||
current_usage=round(current_usage, 2),
|
||||
current_usage=current_usage,
|
||||
has_schedule=False # Default
|
||||
)
|
||||
|
||||
|
@ -1135,4 +1281,43 @@ async def get_available_models(
|
|||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting available models: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"Error getting available models: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"Error getting available models: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/usage-logs")
|
||||
async def get_usage_logs_endpoint(
|
||||
page: int = 0,
|
||||
items_per_page: int = 1000,
|
||||
current_user_id: str = Depends(get_current_user_id_from_jwt)
|
||||
):
|
||||
"""Get detailed usage logs for a user with pagination."""
|
||||
try:
|
||||
# Get Supabase client
|
||||
db = DBConnection()
|
||||
client = await db.client
|
||||
|
||||
# Check if we're in local development mode
|
||||
if config.ENV_MODE == EnvMode.LOCAL:
|
||||
logger.info("Running in local development mode - usage logs are not available")
|
||||
return {
|
||||
"logs": [],
|
||||
"has_more": False,
|
||||
"message": "Usage logs are not available in local development mode"
|
||||
}
|
||||
|
||||
# Validate pagination parameters
|
||||
if page < 0:
|
||||
raise HTTPException(status_code=400, detail="Page must be non-negative")
|
||||
if items_per_page < 1 or items_per_page > 1000:
|
||||
raise HTTPException(status_code=400, detail="Items per page must be between 1 and 1000")
|
||||
|
||||
# Get usage logs
|
||||
result = await get_usage_logs(client, current_user_id, page, items_per_page)
|
||||
|
||||
return result
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting usage logs: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"Error getting usage logs: {str(e)}")
|
|
@ -1,7 +1,6 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
|
@ -27,26 +26,12 @@ import { Badge } from '@/components/ui/badge';
|
|||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ExternalLink, Loader2 } from 'lucide-react';
|
||||
import { isLocalMode } from '@/lib/config';
|
||||
import { useAvailableModels } from '@/hooks/react-query/subscriptions/use-model';
|
||||
import Link from 'next/link';
|
||||
import { OpenInNewWindowIcon } from '@radix-ui/react-icons';
|
||||
import { useUsageLogs } from '@/hooks/react-query/subscriptions/use-billing';
|
||||
import { UsageLogEntry } from '@/lib/api';
|
||||
|
||||
|
||||
interface UsageLogEntry {
|
||||
message_id: string;
|
||||
thread_id: string;
|
||||
created_at: string;
|
||||
content: {
|
||||
usage: {
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
};
|
||||
model: string;
|
||||
};
|
||||
total_tokens: number;
|
||||
estimated_cost: number | string;
|
||||
project_id: string;
|
||||
}
|
||||
|
||||
interface DailyUsage {
|
||||
date: string;
|
||||
|
@ -62,259 +47,32 @@ interface Props {
|
|||
}
|
||||
|
||||
export default function UsageLogs({ accountId }: Props) {
|
||||
const [usageLogs, setUsageLogs] = useState<UsageLogEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [page, setPage] = useState(0);
|
||||
const [allLogs, setAllLogs] = useState<UsageLogEntry[]>([]);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
|
||||
// Use React Query hook instead of manual fetching
|
||||
const {
|
||||
data: modelsData,
|
||||
isLoading: isLoadingModels,
|
||||
error: modelsError,
|
||||
} = useAvailableModels();
|
||||
|
||||
|
||||
const ITEMS_PER_PAGE = 1000;
|
||||
|
||||
// Helper function to normalize model names for better matching
|
||||
const normalizeModelName = (name: string): string => {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[-_.]/g, '') // Remove hyphens, underscores, dots
|
||||
.replace(/\s+/g, '') // Remove spaces
|
||||
.replace(/latest$/, '') // Remove 'latest' suffix
|
||||
.replace(/preview$/, '') // Remove 'preview' suffix
|
||||
.replace(/\d{8}$/, ''); // Remove date suffixes like 20250514
|
||||
};
|
||||
|
||||
// Helper function to find matching pricing for a model
|
||||
const findModelPricing = (
|
||||
modelName: string,
|
||||
pricingData: Record<string, { input: number; output: number }>,
|
||||
) => {
|
||||
// Direct match first
|
||||
if (pricingData[modelName]) {
|
||||
return pricingData[modelName];
|
||||
}
|
||||
|
||||
// Try normalized matching
|
||||
const normalizedTarget = normalizeModelName(modelName);
|
||||
|
||||
for (const [pricingKey, pricingValue] of Object.entries(pricingData)) {
|
||||
const normalizedKey = normalizeModelName(pricingKey);
|
||||
|
||||
// Exact normalized match
|
||||
if (normalizedKey === normalizedTarget) {
|
||||
return pricingValue;
|
||||
}
|
||||
|
||||
// Partial matches - check if one contains the other
|
||||
if (
|
||||
normalizedKey.includes(normalizedTarget) ||
|
||||
normalizedTarget.includes(normalizedKey)
|
||||
) {
|
||||
return pricingValue;
|
||||
}
|
||||
|
||||
// Try matching without provider prefix from pricing key
|
||||
const keyWithoutProvider = pricingKey.replace(/^[^\/]+\//, '');
|
||||
const normalizedKeyWithoutProvider =
|
||||
normalizeModelName(keyWithoutProvider);
|
||||
|
||||
if (
|
||||
normalizedKeyWithoutProvider === normalizedTarget ||
|
||||
normalizedKeyWithoutProvider.includes(normalizedTarget) ||
|
||||
normalizedTarget.includes(normalizedKeyWithoutProvider)
|
||||
) {
|
||||
return pricingValue;
|
||||
}
|
||||
|
||||
// Try matching the end part of the pricing key with the model name
|
||||
const pricingKeyParts = pricingKey.split('/');
|
||||
const lastPart = pricingKeyParts[pricingKeyParts.length - 1];
|
||||
const normalizedLastPart = normalizeModelName(lastPart);
|
||||
|
||||
if (
|
||||
normalizedLastPart === normalizedTarget ||
|
||||
normalizedLastPart.includes(normalizedTarget) ||
|
||||
normalizedTarget.includes(normalizedLastPart)
|
||||
) {
|
||||
return pricingValue;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`No pricing match found for: "${modelName}"`);
|
||||
return null;
|
||||
};
|
||||
|
||||
// Create pricing lookup from models data
|
||||
const modelPricing = useMemo(() => {
|
||||
if (!modelsData?.models) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const pricing: Record<string, { input: number; output: number }> = {};
|
||||
modelsData.models.forEach((model) => {
|
||||
if (
|
||||
model.input_cost_per_million_tokens &&
|
||||
model.output_cost_per_million_tokens
|
||||
) {
|
||||
// Use the model.id as the key, which should match the model names in usage logs
|
||||
pricing[model.id] = {
|
||||
input: model.input_cost_per_million_tokens,
|
||||
output: model.output_cost_per_million_tokens,
|
||||
};
|
||||
|
||||
// Also try to match by display_name and short_name if they exist
|
||||
if (model.display_name && model.display_name !== model.id) {
|
||||
pricing[model.display_name] = {
|
||||
input: model.input_cost_per_million_tokens,
|
||||
output: model.output_cost_per_million_tokens,
|
||||
};
|
||||
}
|
||||
|
||||
if (model.short_name && model.short_name !== model.id) {
|
||||
pricing[model.short_name] = {
|
||||
input: model.input_cost_per_million_tokens,
|
||||
output: model.output_cost_per_million_tokens,
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log(
|
||||
'Pricing lookup ready with',
|
||||
Object.keys(pricing).length,
|
||||
'entries',
|
||||
);
|
||||
return pricing;
|
||||
}, [modelsData, isLoadingModels, modelsError]);
|
||||
|
||||
const calculateTokenCost = (
|
||||
promptTokens: number,
|
||||
completionTokens: number,
|
||||
model: string,
|
||||
): number | string => {
|
||||
// Use the more lenient matching function
|
||||
const costs = findModelPricing(model, modelPricing);
|
||||
|
||||
if (costs) {
|
||||
// Convert from per-million to per-token costs
|
||||
return (
|
||||
(promptTokens / 1000000) * costs.input +
|
||||
(completionTokens / 1000000) * costs.output
|
||||
);
|
||||
}
|
||||
|
||||
// Return "unknown" instead of fallback cost
|
||||
return 'unknown';
|
||||
};
|
||||
|
||||
const fetchUsageLogs = async (
|
||||
pageNum: number = 0,
|
||||
append: boolean = false,
|
||||
) => {
|
||||
try {
|
||||
if (!append) setLoading(true);
|
||||
else setLoadingMore(true);
|
||||
|
||||
const supabase = createClient();
|
||||
|
||||
// First, get all thread IDs for this user
|
||||
const { data: threads, error: threadsError } = await supabase
|
||||
.from('threads')
|
||||
.select('thread_id')
|
||||
.eq('account_id', accountId);
|
||||
|
||||
if (threadsError) throw threadsError;
|
||||
|
||||
if (!threads || threads.length === 0) {
|
||||
setUsageLogs([]);
|
||||
setLoading(false);
|
||||
setLoadingMore(false);
|
||||
setHasMore(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const threadIds = threads.map((t) => t.thread_id);
|
||||
|
||||
// Then fetch usage messages with pagination, including thread project info
|
||||
const { data: messages, error: messagesError } = await supabase
|
||||
.from('messages')
|
||||
.select(
|
||||
`
|
||||
message_id,
|
||||
thread_id,
|
||||
created_at,
|
||||
content,
|
||||
threads!inner(project_id)
|
||||
`,
|
||||
)
|
||||
.in('thread_id', threadIds)
|
||||
.eq('type', 'assistant_response_end')
|
||||
.order('created_at', { ascending: false })
|
||||
.range(pageNum * ITEMS_PER_PAGE, (pageNum + 1) * ITEMS_PER_PAGE - 1);
|
||||
|
||||
if (messagesError) throw messagesError;
|
||||
|
||||
const processedLogs: UsageLogEntry[] = (messages || []).map((message) => {
|
||||
const usage = message.content?.usage || {
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
};
|
||||
const model = message.content?.model || 'unknown';
|
||||
const totalTokens = usage.prompt_tokens + usage.completion_tokens;
|
||||
const estimatedCost = calculateTokenCost(
|
||||
usage.prompt_tokens,
|
||||
usage.completion_tokens,
|
||||
model,
|
||||
);
|
||||
|
||||
return {
|
||||
message_id: message.message_id,
|
||||
thread_id: message.thread_id,
|
||||
created_at: message.created_at,
|
||||
content: {
|
||||
usage,
|
||||
model,
|
||||
},
|
||||
total_tokens: totalTokens,
|
||||
estimated_cost: estimatedCost,
|
||||
project_id: message.threads?.[0]?.project_id || 'unknown',
|
||||
};
|
||||
});
|
||||
|
||||
if (append) {
|
||||
setUsageLogs((prev) => [...prev, ...processedLogs]);
|
||||
} else {
|
||||
setUsageLogs(processedLogs);
|
||||
}
|
||||
|
||||
setHasMore(processedLogs.length === ITEMS_PER_PAGE);
|
||||
} catch (err) {
|
||||
console.error('Error fetching usage logs:', err);
|
||||
setError(
|
||||
err instanceof Error ? err.message : 'Failed to fetch usage logs',
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoadingMore(false);
|
||||
}
|
||||
};
|
||||
// Use React Query hook for the current page
|
||||
const { data: currentPageData, isLoading, error, refetch } = useUsageLogs(page, ITEMS_PER_PAGE);
|
||||
|
||||
// Update accumulated logs when new data arrives
|
||||
useEffect(() => {
|
||||
// Only fetch usage logs after models data is loaded
|
||||
if (!isLoadingModels && modelsData) {
|
||||
fetchUsageLogs(0, false);
|
||||
if (currentPageData) {
|
||||
if (page === 0) {
|
||||
// First page - replace all logs
|
||||
setAllLogs(currentPageData.logs || []);
|
||||
} else {
|
||||
// Subsequent pages - append to existing logs
|
||||
setAllLogs(prev => [...prev, ...(currentPageData.logs || [])]);
|
||||
}
|
||||
setHasMore(currentPageData.has_more || false);
|
||||
}
|
||||
}, [accountId, isLoadingModels, modelsData]);
|
||||
}, [currentPageData, page]);
|
||||
|
||||
const loadMore = () => {
|
||||
const nextPage = page + 1;
|
||||
setPage(nextPage);
|
||||
fetchUsageLogs(nextPage, true);
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
|
@ -322,8 +80,8 @@ export default function UsageLogs({ accountId }: Props) {
|
|||
};
|
||||
|
||||
const formatCost = (cost: number | string) => {
|
||||
if (typeof cost === 'string') {
|
||||
return cost;
|
||||
if (typeof cost === 'string' || cost === 0) {
|
||||
return typeof cost === 'string' ? cost : '$0.0000';
|
||||
}
|
||||
return `$${cost.toFixed(4)}`;
|
||||
};
|
||||
|
@ -380,24 +138,9 @@ export default function UsageLogs({ accountId }: Props) {
|
|||
);
|
||||
};
|
||||
|
||||
if (isLocalMode()) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Usage Logs</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="p-4 bg-muted/30 border border-border rounded-lg text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Usage logs are not available in local development mode
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading || isLoadingModels) {
|
||||
|
||||
if (isLoading && page === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
@ -415,7 +158,7 @@ export default function UsageLogs({ accountId }: Props) {
|
|||
);
|
||||
}
|
||||
|
||||
if (error || modelsError) {
|
||||
if (error) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
@ -424,11 +167,7 @@ export default function UsageLogs({ accountId }: Props) {
|
|||
<CardContent>
|
||||
<div className="p-4 bg-destructive/10 border border-destructive/20 rounded-lg">
|
||||
<p className="text-sm text-destructive">
|
||||
Error:{' '}
|
||||
{error ||
|
||||
(modelsError instanceof Error
|
||||
? modelsError.message
|
||||
: 'Failed to load data')}
|
||||
Error: {error.message || 'Failed to load usage logs'}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
@ -436,8 +175,26 @@ export default function UsageLogs({ accountId }: Props) {
|
|||
);
|
||||
}
|
||||
|
||||
const dailyUsage = groupLogsByDate(usageLogs);
|
||||
const totalUsage = usageLogs.reduce(
|
||||
// Handle local development mode message
|
||||
if (currentPageData?.message) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Usage Logs</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="p-4 bg-muted/30 border border-border rounded-lg text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{currentPageData.message}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const dailyUsage = groupLogsByDate(allLogs);
|
||||
const totalUsage = allLogs.reduce(
|
||||
(sum, log) =>
|
||||
sum + (typeof log.estimated_cost === 'number' ? log.estimated_cost : 0),
|
||||
0,
|
||||
|
@ -558,10 +315,10 @@ export default function UsageLogs({ accountId }: Props) {
|
|||
<div className="flex justify-center pt-6">
|
||||
<Button
|
||||
onClick={loadMore}
|
||||
disabled={loadingMore}
|
||||
disabled={isLoading}
|
||||
variant="outline"
|
||||
>
|
||||
{loadingMore ? (
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Loading...
|
||||
|
|
|
@ -8,4 +8,9 @@ export const subscriptionKeys = createQueryKeys({
|
|||
export const modelKeys = createQueryKeys({
|
||||
all: ['models'] as const,
|
||||
available: ['models', 'available'] as const,
|
||||
});
|
||||
|
||||
export const usageKeys = createQueryKeys({
|
||||
all: ['usage'] as const,
|
||||
logs: (page?: number, itemsPerPage?: number) => [...usageKeys.all, 'logs', { page, itemsPerPage }] as const,
|
||||
});
|
|
@ -7,7 +7,8 @@ import {
|
|||
getAvailableModels,
|
||||
CreateCheckoutSessionRequest
|
||||
} from '@/lib/api';
|
||||
import { modelKeys } from './keys';
|
||||
import { billingApi } from '@/lib/api-enhanced';
|
||||
import { modelKeys, usageKeys } from './keys';
|
||||
|
||||
export const useAvailableModels = createQueryHook(
|
||||
modelKeys.available,
|
||||
|
@ -40,4 +41,15 @@ export const useCreateCheckoutSession = createMutationHook(
|
|||
resource: 'billing'
|
||||
}
|
||||
}
|
||||
);
|
||||
);
|
||||
|
||||
export const useUsageLogs = (page: number = 0, itemsPerPage: number = 1000) =>
|
||||
createQueryHook(
|
||||
usageKeys.logs(page, itemsPerPage),
|
||||
() => billingApi.getUsageLogs(page, itemsPerPage),
|
||||
{
|
||||
staleTime: 30 * 1000, // 30 seconds
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: false,
|
||||
}
|
||||
)();
|
|
@ -15,7 +15,8 @@ import {
|
|||
SubscriptionStatus,
|
||||
AvailableModelsResponse,
|
||||
BillingStatusResponse,
|
||||
BillingError
|
||||
BillingError,
|
||||
UsageLogsResponse
|
||||
} from './api';
|
||||
|
||||
export * from './api';
|
||||
|
@ -430,6 +431,17 @@ export const billingApi = {
|
|||
|
||||
return result.data || null;
|
||||
},
|
||||
|
||||
async getUsageLogs(page: number = 0, itemsPerPage: number = 1000): Promise<UsageLogsResponse | null> {
|
||||
const result = await backendApi.get(
|
||||
`/billing/usage-logs?page=${page}&items_per_page=${itemsPerPage}`,
|
||||
{
|
||||
errorContext: { operation: 'load usage logs', resource: 'usage history' },
|
||||
}
|
||||
);
|
||||
|
||||
return result.data || null;
|
||||
},
|
||||
};
|
||||
|
||||
export const healthApi = {
|
||||
|
|
|
@ -1641,6 +1641,28 @@ export interface AvailableModelsResponse {
|
|||
total_models: number;
|
||||
}
|
||||
|
||||
export interface UsageLogEntry {
|
||||
message_id: string;
|
||||
thread_id: string;
|
||||
created_at: string;
|
||||
content: {
|
||||
usage: {
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
};
|
||||
model: string;
|
||||
};
|
||||
total_tokens: number;
|
||||
estimated_cost: number;
|
||||
project_id: string;
|
||||
}
|
||||
|
||||
export interface UsageLogsResponse {
|
||||
logs: UsageLogEntry[];
|
||||
has_more: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface CreateCheckoutSessionResponse {
|
||||
status:
|
||||
| 'upgraded'
|
||||
|
@ -2005,7 +2027,7 @@ export const getWorkflows = async (projectId?: string): Promise<Workflow[]> => {
|
|||
throw new NoAccessTokenAvailableError();
|
||||
}
|
||||
|
||||
let url = `${API_URL}/workflows`;
|
||||
const url = `${API_URL}/workflows`;
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${session.access_token}`,
|
||||
};
|
||||
|
|
Loading…
Reference in New Issue