fix(billing): implement usage logs retrieval and display in frontend with pagination support

This commit is contained in:
sharath 2025-06-27 17:01:58 +00:00
parent 235aec6d1e
commit 87908cd526
No known key found for this signature in database
6 changed files with 328 additions and 335 deletions

View File

@ -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)}")

View File

@ -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...

View File

@ -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,
});

View File

@ -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,
}
)();

View File

@ -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 = {

View File

@ -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}`,
};