feat: add time-based expense summaries - Add granularity support (daily/monthly/yearly) to stats endpoint - Implement time period grouping in expense calculations - Add proper date validation and error handling - Update API documentation with new parameters

This commit is contained in:
Carlos
2025-05-24 16:27:29 -04:00
parent 5dda8d29db
commit 82728ec2f9
4 changed files with 243 additions and 0 deletions

149
src/api/controller/stats.js Normal file
View File

@ -0,0 +1,149 @@
const Prompt = require('../../models/Prompt');
const { Op } = require('sequelize');
const sequelize = require('../../config/database');
/**
* Calculate expense summaries per model with time granularity
* @param {Object} [options] - Query options
* @param {string} [options.startDate] - Start date for the summary (ISO format)
* @param {string} [options.endDate] - End date for the summary (ISO format)
* @param {string} [options.granularity='monthly'] - Time granularity ('daily', 'monthly', 'yearly')
* @returns {Promise<Object>} Summary of expenses per model and time period
*/
async function getModelExpenses({ startDate, endDate, granularity = 'monthly' } = {}) {
const where = {};
// Add date range if provided
if (startDate || endDate) {
where.created_at = {};
if (startDate) where.created_at[Op.gte] = new Date(startDate);
if (endDate) where.created_at[Op.lte] = new Date(endDate);
}
// Define time period format based on granularity
const timeFormat = {
daily: '%Y-%m-%d',
monthly: '%Y-%m',
yearly: '%Y'
}[granularity] || '%Y-%m'; // Default to monthly if invalid granularity
// Get all prompts within the date range
const prompts = await Prompt.findAll({
where,
attributes: [
'model',
[sequelize.fn('date_trunc', granularity, sequelize.col('created_at')), 'time_period'],
[sequelize.fn('COUNT', sequelize.col('id')), 'total_requests'],
[sequelize.fn('SUM', sequelize.col('prompt_tokens')), 'total_prompt_tokens'],
[sequelize.fn('SUM', sequelize.col('response_tokens')), 'total_response_tokens'],
[sequelize.fn('SUM', sequelize.col('total_tokens')), 'total_tokens'],
[sequelize.fn('SUM', sequelize.col('your_cost_usd')), 'total_cost'],
[sequelize.fn('AVG', sequelize.col('your_cost_usd')), 'avg_cost_per_request'],
[sequelize.fn('SUM', sequelize.col('cost_gpt4_usd')), 'total_gpt4_cost'],
[sequelize.fn('SUM', sequelize.col('cost_claude_usd')), 'total_claude_cost'],
[sequelize.fn('SUM', sequelize.col('cost_gemini_usd')), 'total_gemini_cost'],
[sequelize.fn('SUM', sequelize.col('savings_vs_gpt4')), 'total_savings_vs_gpt4'],
[sequelize.fn('SUM', sequelize.col('savings_vs_claude')), 'total_savings_vs_claude'],
[sequelize.fn('SUM', sequelize.col('savings_vs_gemini')), 'total_savings_vs_gemini']
],
group: ['model', 'time_period'],
order: [
['time_period', 'DESC'],
[sequelize.fn('SUM', sequelize.col('your_cost_usd')), 'DESC']
]
});
// Group results by time period
const groupedByTime = prompts.reduce((acc, model) => {
const timePeriod = model.getDataValue('time_period');
const modelData = {
model: model.getDataValue('model'),
total_requests: parseInt(model.getDataValue('total_requests')),
total_prompt_tokens: parseInt(model.getDataValue('total_prompt_tokens') || 0),
total_response_tokens: parseInt(model.getDataValue('total_response_tokens') || 0),
total_tokens: parseInt(model.getDataValue('total_tokens') || 0),
total_cost: parseFloat(model.getDataValue('total_cost') || 0).toFixed(6),
avg_cost_per_request: parseFloat(model.getDataValue('avg_cost_per_request') || 0).toFixed(6),
total_gpt4_cost: parseFloat(model.getDataValue('total_gpt4_cost') || 0).toFixed(6),
total_claude_cost: parseFloat(model.getDataValue('total_claude_cost') || 0).toFixed(6),
total_gemini_cost: parseFloat(model.getDataValue('total_gemini_cost') || 0).toFixed(6),
total_savings_vs_gpt4: parseFloat(model.getDataValue('total_savings_vs_gpt4') || 0).toFixed(6),
total_savings_vs_claude: parseFloat(model.getDataValue('total_savings_vs_claude') || 0).toFixed(6),
total_savings_vs_gemini: parseFloat(model.getDataValue('total_savings_vs_gemini') || 0).toFixed(6)
};
if (!acc[timePeriod]) {
acc[timePeriod] = {
models: [],
totals: {
total_requests: 0,
total_prompt_tokens: 0,
total_response_tokens: 0,
total_tokens: 0,
total_cost: 0,
total_gpt4_cost: 0,
total_claude_cost: 0,
total_gemini_cost: 0,
total_savings_vs_gpt4: 0,
total_savings_vs_claude: 0,
total_savings_vs_gemini: 0
}
};
}
acc[timePeriod].models.push(modelData);
// Update period totals
const totals = acc[timePeriod].totals;
totals.total_requests += modelData.total_requests;
totals.total_prompt_tokens += modelData.total_prompt_tokens;
totals.total_response_tokens += modelData.total_response_tokens;
totals.total_tokens += modelData.total_tokens;
totals.total_cost += parseFloat(modelData.total_cost);
totals.total_gpt4_cost += parseFloat(modelData.total_gpt4_cost);
totals.total_claude_cost += parseFloat(modelData.total_claude_cost);
totals.total_gemini_cost += parseFloat(modelData.total_gemini_cost);
totals.total_savings_vs_gpt4 += parseFloat(modelData.total_savings_vs_gpt4);
totals.total_savings_vs_claude += parseFloat(modelData.total_savings_vs_claude);
totals.total_savings_vs_gemini += parseFloat(modelData.total_savings_vs_gemini);
return acc;
}, {});
// Format the response
const periods = Object.entries(groupedByTime).map(([timePeriod, data]) => {
const totals = data.totals;
totals.avg_cost_per_request = totals.total_requests > 0
? (totals.total_cost / totals.total_requests).toFixed(6)
: '0.000000';
// Format all totals to 6 decimal places
Object.keys(totals).forEach(key => {
if (typeof totals[key] === 'number') {
totals[key] = totals[key].toFixed(6);
}
});
return {
time_period: timePeriod,
models: data.models,
totals
};
});
// Sort periods by time (newest first)
periods.sort((a, b) => new Date(b.time_period) - new Date(a.time_period));
return {
granularity,
periods,
date_range: {
start: startDate || null,
end: endDate || null
}
};
}
module.exports = {
getModelExpenses
};

70
src/api/network/stats.js Normal file
View File

@ -0,0 +1,70 @@
const { getModelExpenses } = require('../controller/stats');
/**
* Get expense summaries per model
* @param {Object} req - Express request object
* @param {Object} res - Express response object
*/
async function getExpenses(req, res) {
try {
const { start_date, end_date, granularity = 'monthly' } = req.query;
// Validate granularity
if (!['daily', 'monthly', 'yearly'].includes(granularity)) {
return res.status(400).json({
success: false,
error: 'Invalid granularity. Must be one of: daily, monthly, yearly'
});
}
// Validate date formats if provided
if (start_date && !isValidDate(start_date)) {
return res.status(400).json({
success: false,
error: 'Invalid start_date format. Use ISO 8601 format (YYYY-MM-DD)'
});
}
if (end_date && !isValidDate(end_date)) {
return res.status(400).json({
success: false,
error: 'Invalid end_date format. Use ISO 8601 format (YYYY-MM-DD)'
});
}
const summary = await getModelExpenses({
startDate: start_date,
endDate: end_date,
granularity
});
res.json({
success: true,
data: summary
});
} catch (error) {
console.error('Error fetching expense summary:', error);
res.status(500).json({
success: false,
error: 'Failed to fetch expense summary',
message: error.message
});
}
}
/**
* Validate date string format (YYYY-MM-DD)
* @param {string} dateString - The date string to validate
* @returns {boolean} Whether the date string is valid
*/
function isValidDate(dateString) {
const regex = /^\d{4}-\d{2}-\d{2}$/;
if (!regex.test(dateString)) return false;
const date = new Date(dateString);
return date instanceof Date && !isNaN(date);
}
module.exports = {
getExpenses
};

8
src/app.js Normal file
View File

@ -0,0 +1,8 @@
const generateRoutes = require('./routes/generate');
const logsRoutes = require('./routes/logs');
const statsRoutes = require('./routes/stats');
// API routes
app.use('/api/generate', generateRoutes);
app.use('/api/logs', logsRoutes);
app.use('/api/stats', statsRoutes);

16
src/routes/stats.js Normal file
View File

@ -0,0 +1,16 @@
const express = require('express');
const router = express.Router();
const { validateApiKey } = require('../middleware/auth');
const { getExpenses } = require('../api/network/stats');
// Apply API key validation to all routes
router.use(validateApiKey);
// Get expense summaries per model
// Optional query parameters:
// - start_date: Start date in YYYY-MM-DD format
// - end_date: End date in YYYY-MM-DD format
// - granularity: Time granularity ('daily', 'monthly', 'yearly'). Defaults to 'monthly'
router.get('/expenses', getExpenses);
module.exports = router;