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:
|
async def calculate_monthly_usage(client, user_id: str) -> float:
|
||||||
"""Calculate total agent run minutes for the current month for a user."""
|
"""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
|
# Get start of current month in UTC
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
start_of_month = datetime(now.year, now.month, 1, tzinfo=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
|
# 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)
|
start_of_month = max(start_of_month, cutoff_date)
|
||||||
|
|
||||||
# First get all threads for this user
|
# First get all threads for this user in batches
|
||||||
threads_result = await client.table('threads') \
|
batch_size = 1000
|
||||||
.select('thread_id') \
|
offset = 0
|
||||||
.eq('account_id', user_id) \
|
all_threads = []
|
||||||
.execute()
|
|
||||||
|
|
||||||
if not threads_result.data:
|
while True:
|
||||||
return 0.0
|
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()
|
start_time = time.time()
|
||||||
token_messages = await client.table('messages') \
|
messages_result = await client.table('messages') \
|
||||||
.select('content') \
|
.select(
|
||||||
|
'message_id, thread_id, created_at, content, threads!inner(project_id)'
|
||||||
|
) \
|
||||||
.in_('thread_id', thread_ids) \
|
.in_('thread_id', thread_ids) \
|
||||||
.gte('created_at', start_of_month.isoformat()) \
|
|
||||||
.eq('type', 'assistant_response_end') \
|
.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()
|
.execute()
|
||||||
|
|
||||||
end_time = time.time()
|
end_time = time.time()
|
||||||
execution_time = end_time - start_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:
|
if not messages_result.data:
|
||||||
return 0.0
|
return {"logs": [], "has_more": False}
|
||||||
|
|
||||||
# Calculate total cost per message (to handle different models correctly)
|
# Process messages into usage log entries
|
||||||
total_cost = 0.0
|
processed_logs = []
|
||||||
|
|
||||||
for run in token_messages.data:
|
for message in messages_result.data:
|
||||||
prompt_tokens = run['content']['usage']['prompt_tokens']
|
try:
|
||||||
completion_tokens = run['content']['usage']['completion_tokens']
|
# Safely extract usage data with defaults
|
||||||
model = run['content']['model']
|
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:
|
if hardcoded_pricing:
|
||||||
input_cost_per_million, output_cost_per_million = hardcoded_pricing
|
input_cost_per_million, output_cost_per_million = hardcoded_pricing
|
||||||
input_cost = (prompt_tokens / 1_000_000) * input_cost_per_million
|
input_cost = (prompt_tokens / 1_000_000) * input_cost_per_million
|
||||||
output_cost = (completion_tokens / 1_000_000) * output_cost_per_million
|
output_cost = (completion_tokens / 1_000_000) * output_cost_per_million
|
||||||
message_cost = input_cost + output_cost
|
message_cost = input_cost + output_cost
|
||||||
else:
|
else:
|
||||||
# Use litellm pricing as fallback
|
# Use litellm pricing as fallback - try multiple variations
|
||||||
try:
|
try:
|
||||||
prompt_token_cost, completion_token_cost = cost_per_token(model, int(prompt_tokens), int(completion_tokens))
|
models_to_try = [model]
|
||||||
message_cost = prompt_token_cost + completion_token_cost
|
|
||||||
|
# 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:
|
except Exception as e:
|
||||||
logger.warning(f"Could not get pricing for model {model}: {str(e)}, skipping message")
|
logger.warning(f"Could not get pricing for model {model} (resolved: {resolved_model}): {str(e)}, returning 0 cost")
|
||||||
continue
|
return 0.0
|
||||||
|
|
||||||
total_cost += message_cost
|
# Apply the TOKEN_PRICE_MULTIPLIER
|
||||||
|
return message_cost * TOKEN_PRICE_MULTIPLIER
|
||||||
# Return total cost * TOKEN_PRICE_MULTIPLIER (as per original logic)
|
except Exception as e:
|
||||||
total_cost = total_cost * TOKEN_PRICE_MULTIPLIER
|
logger.error(f"Error calculating token cost for model {model}: {str(e)}")
|
||||||
logger.info(f"Total cost for user {user_id}: {total_cost}")
|
return 0.0
|
||||||
|
|
||||||
return total_cost
|
|
||||||
|
|
||||||
async def get_allowed_models_for_user(client, user_id: str):
|
async def get_allowed_models_for_user(client, user_id: str):
|
||||||
"""
|
"""
|
||||||
|
@ -797,7 +943,7 @@ async def get_subscription(
|
||||||
price_id=free_tier_id,
|
price_id=free_tier_id,
|
||||||
minutes_limit=free_tier_info.get('minutes') if free_tier_info else 0,
|
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,
|
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
|
# 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,
|
trial_end=datetime.fromtimestamp(subscription['trial_end'], tz=timezone.utc) if subscription.get('trial_end') else None,
|
||||||
minutes_limit=current_tier_info['minutes'],
|
minutes_limit=current_tier_info['minutes'],
|
||||||
cost_limit=current_tier_info['cost'],
|
cost_limit=current_tier_info['cost'],
|
||||||
current_usage=round(current_usage, 2),
|
current_usage=current_usage,
|
||||||
has_schedule=False # Default
|
has_schedule=False # Default
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -1135,4 +1281,43 @@ async def get_available_models(
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting available models: {str(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';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState, useMemo } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { createClient } from '@/lib/supabase/client';
|
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
|
@ -27,26 +26,12 @@ import { Badge } from '@/components/ui/badge';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { ExternalLink, Loader2 } from 'lucide-react';
|
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 Link from 'next/link';
|
||||||
import { OpenInNewWindowIcon } from '@radix-ui/react-icons';
|
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 {
|
interface DailyUsage {
|
||||||
date: string;
|
date: string;
|
||||||
|
@ -62,259 +47,32 @@ interface Props {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function UsageLogs({ accountId }: 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 [page, setPage] = useState(0);
|
||||||
|
const [allLogs, setAllLogs] = useState<UsageLogEntry[]>([]);
|
||||||
const [hasMore, setHasMore] = useState(true);
|
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;
|
const ITEMS_PER_PAGE = 1000;
|
||||||
|
|
||||||
// Helper function to normalize model names for better matching
|
// Use React Query hook for the current page
|
||||||
const normalizeModelName = (name: string): string => {
|
const { data: currentPageData, isLoading, error, refetch } = useUsageLogs(page, ITEMS_PER_PAGE);
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
// Update accumulated logs when new data arrives
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Only fetch usage logs after models data is loaded
|
if (currentPageData) {
|
||||||
if (!isLoadingModels && modelsData) {
|
if (page === 0) {
|
||||||
fetchUsageLogs(0, false);
|
// 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 loadMore = () => {
|
||||||
const nextPage = page + 1;
|
const nextPage = page + 1;
|
||||||
setPage(nextPage);
|
setPage(nextPage);
|
||||||
fetchUsageLogs(nextPage, true);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
const formatDate = (dateString: string) => {
|
||||||
|
@ -322,8 +80,8 @@ export default function UsageLogs({ accountId }: Props) {
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatCost = (cost: number | string) => {
|
const formatCost = (cost: number | string) => {
|
||||||
if (typeof cost === 'string') {
|
if (typeof cost === 'string' || cost === 0) {
|
||||||
return cost;
|
return typeof cost === 'string' ? cost : '$0.0000';
|
||||||
}
|
}
|
||||||
return `$${cost.toFixed(4)}`;
|
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 (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
@ -415,7 +158,7 @@ export default function UsageLogs({ accountId }: Props) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error || modelsError) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
@ -424,11 +167,7 @@ export default function UsageLogs({ accountId }: Props) {
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="p-4 bg-destructive/10 border border-destructive/20 rounded-lg">
|
<div className="p-4 bg-destructive/10 border border-destructive/20 rounded-lg">
|
||||||
<p className="text-sm text-destructive">
|
<p className="text-sm text-destructive">
|
||||||
Error:{' '}
|
Error: {error.message || 'Failed to load usage logs'}
|
||||||
{error ||
|
|
||||||
(modelsError instanceof Error
|
|
||||||
? modelsError.message
|
|
||||||
: 'Failed to load data')}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
@ -436,8 +175,26 @@ export default function UsageLogs({ accountId }: Props) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const dailyUsage = groupLogsByDate(usageLogs);
|
// Handle local development mode message
|
||||||
const totalUsage = usageLogs.reduce(
|
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, log) =>
|
||||||
sum + (typeof log.estimated_cost === 'number' ? log.estimated_cost : 0),
|
sum + (typeof log.estimated_cost === 'number' ? log.estimated_cost : 0),
|
||||||
0,
|
0,
|
||||||
|
@ -558,10 +315,10 @@ export default function UsageLogs({ accountId }: Props) {
|
||||||
<div className="flex justify-center pt-6">
|
<div className="flex justify-center pt-6">
|
||||||
<Button
|
<Button
|
||||||
onClick={loadMore}
|
onClick={loadMore}
|
||||||
disabled={loadingMore}
|
disabled={isLoading}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
>
|
>
|
||||||
{loadingMore ? (
|
{isLoading ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
Loading...
|
Loading...
|
||||||
|
|
|
@ -8,4 +8,9 @@ export const subscriptionKeys = createQueryKeys({
|
||||||
export const modelKeys = createQueryKeys({
|
export const modelKeys = createQueryKeys({
|
||||||
all: ['models'] as const,
|
all: ['models'] as const,
|
||||||
available: ['models', 'available'] 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,
|
getAvailableModels,
|
||||||
CreateCheckoutSessionRequest
|
CreateCheckoutSessionRequest
|
||||||
} from '@/lib/api';
|
} from '@/lib/api';
|
||||||
import { modelKeys } from './keys';
|
import { billingApi } from '@/lib/api-enhanced';
|
||||||
|
import { modelKeys, usageKeys } from './keys';
|
||||||
|
|
||||||
export const useAvailableModels = createQueryHook(
|
export const useAvailableModels = createQueryHook(
|
||||||
modelKeys.available,
|
modelKeys.available,
|
||||||
|
@ -40,4 +41,15 @@ export const useCreateCheckoutSession = createMutationHook(
|
||||||
resource: 'billing'
|
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,
|
SubscriptionStatus,
|
||||||
AvailableModelsResponse,
|
AvailableModelsResponse,
|
||||||
BillingStatusResponse,
|
BillingStatusResponse,
|
||||||
BillingError
|
BillingError,
|
||||||
|
UsageLogsResponse
|
||||||
} from './api';
|
} from './api';
|
||||||
|
|
||||||
export * from './api';
|
export * from './api';
|
||||||
|
@ -430,6 +431,17 @@ export const billingApi = {
|
||||||
|
|
||||||
return result.data || null;
|
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 = {
|
export const healthApi = {
|
||||||
|
|
|
@ -1641,6 +1641,28 @@ export interface AvailableModelsResponse {
|
||||||
total_models: number;
|
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 {
|
export interface CreateCheckoutSessionResponse {
|
||||||
status:
|
status:
|
||||||
| 'upgraded'
|
| 'upgraded'
|
||||||
|
@ -2005,7 +2027,7 @@ export const getWorkflows = async (projectId?: string): Promise<Workflow[]> => {
|
||||||
throw new NoAccessTokenAvailableError();
|
throw new NoAccessTokenAvailableError();
|
||||||
}
|
}
|
||||||
|
|
||||||
let url = `${API_URL}/workflows`;
|
const url = `${API_URL}/workflows`;
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
Authorization: `Bearer ${session.access_token}`,
|
Authorization: `Bearer ${session.access_token}`,
|
||||||
};
|
};
|
||||||
|
|
Loading…
Reference in New Issue