mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 20:00:47 +00:00
feat(usage): implement cost tracking backend and pricing configuration
- Add pricing constants with default rates for all providers - Update localDb to support pricing configuration schema - Add cost calculation logic to usageDb - Add pricing management API endpoints - Fix provider alias mapping for accurate cost lookups 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
+157
-1
@@ -41,7 +41,8 @@ const defaultData = {
|
||||
settings: {
|
||||
cloudEnabled: false,
|
||||
stickyRoundRobinLimit: 3
|
||||
}
|
||||
},
|
||||
pricing: {} // NEW: pricing configuration
|
||||
};
|
||||
|
||||
// Singleton instance
|
||||
@@ -528,3 +529,158 @@ export async function isCloudEnabled() {
|
||||
return settings.cloudEnabled === true;
|
||||
}
|
||||
|
||||
// ============ Pricing ============
|
||||
|
||||
/**
|
||||
* Get pricing configuration
|
||||
* Returns merged user pricing with defaults
|
||||
*/
|
||||
export async function getPricing() {
|
||||
const db = await getDb();
|
||||
const userPricing = db.data.pricing || {};
|
||||
|
||||
// Import default pricing
|
||||
const { getDefaultPricing } = await import("@/shared/constants/pricing.js");
|
||||
const defaultPricing = getDefaultPricing();
|
||||
|
||||
// Merge user pricing with defaults
|
||||
// User pricing overrides defaults for specific provider/model combinations
|
||||
const mergedPricing = {};
|
||||
|
||||
for (const [provider, models] of Object.entries(defaultPricing)) {
|
||||
mergedPricing[provider] = { ...models };
|
||||
|
||||
// Apply user overrides if they exist
|
||||
if (userPricing[provider]) {
|
||||
for (const [model, pricing] of Object.entries(userPricing[provider])) {
|
||||
if (mergedPricing[provider][model]) {
|
||||
mergedPricing[provider][model] = { ...mergedPricing[provider][model], ...pricing };
|
||||
} else {
|
||||
mergedPricing[provider][model] = pricing;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add any user-only pricing entries
|
||||
for (const [provider, models] of Object.entries(userPricing)) {
|
||||
if (!mergedPricing[provider]) {
|
||||
mergedPricing[provider] = { ...models };
|
||||
} else {
|
||||
for (const [model, pricing] of Object.entries(models)) {
|
||||
if (!mergedPricing[provider][model]) {
|
||||
mergedPricing[provider][model] = pricing;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mergedPricing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pricing for a specific provider and model
|
||||
*/
|
||||
export async function getPricingForModel(provider, model) {
|
||||
const pricing = await getPricing();
|
||||
|
||||
// Try direct lookup
|
||||
if (pricing[provider] && pricing[provider][model]) {
|
||||
return pricing[provider][model];
|
||||
}
|
||||
|
||||
// Try mapping provider ID to alias
|
||||
// We need to duplicate the mapping here or import it
|
||||
// Since we can't easily import from open-sse, we'll implement the mapping locally
|
||||
const PROVIDER_ID_TO_ALIAS = {
|
||||
claude: "cc",
|
||||
codex: "cx",
|
||||
"gemini-cli": "gc",
|
||||
qwen: "qw",
|
||||
iflow: "if",
|
||||
antigravity: "ag",
|
||||
github: "gh",
|
||||
openai: "openai",
|
||||
anthropic: "anthropic",
|
||||
gemini: "gemini",
|
||||
openrouter: "openrouter",
|
||||
glm: "glm",
|
||||
kimi: "kimi",
|
||||
minimax: "minimax",
|
||||
};
|
||||
|
||||
const alias = PROVIDER_ID_TO_ALIAS[provider];
|
||||
if (alias && pricing[alias]) {
|
||||
return pricing[alias][model] || null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update pricing configuration
|
||||
* @param {object} pricingData - New pricing data to merge
|
||||
*/
|
||||
export async function updatePricing(pricingData) {
|
||||
const db = await getDb();
|
||||
|
||||
// Ensure pricing object exists
|
||||
if (!db.data.pricing) {
|
||||
db.data.pricing = {};
|
||||
}
|
||||
|
||||
// Merge new pricing data
|
||||
for (const [provider, models] of Object.entries(pricingData)) {
|
||||
if (!db.data.pricing[provider]) {
|
||||
db.data.pricing[provider] = {};
|
||||
}
|
||||
|
||||
for (const [model, pricing] of Object.entries(models)) {
|
||||
db.data.pricing[provider][model] = pricing;
|
||||
}
|
||||
}
|
||||
|
||||
await db.write();
|
||||
return db.data.pricing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset pricing to defaults for specific provider/model
|
||||
* @param {string} provider - Provider ID
|
||||
* @param {string} model - Model ID (optional, if not provided resets entire provider)
|
||||
*/
|
||||
export async function resetPricing(provider, model) {
|
||||
const db = await getDb();
|
||||
|
||||
if (!db.data.pricing) {
|
||||
db.data.pricing = {};
|
||||
}
|
||||
|
||||
if (model) {
|
||||
// Reset specific model
|
||||
if (db.data.pricing[provider]) {
|
||||
delete db.data.pricing[provider][model];
|
||||
// Clean up empty provider objects
|
||||
if (Object.keys(db.data.pricing[provider]).length === 0) {
|
||||
delete db.data.pricing[provider];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Reset entire provider
|
||||
delete db.data.pricing[provider];
|
||||
}
|
||||
|
||||
await db.write();
|
||||
return db.data.pricing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all pricing to defaults
|
||||
*/
|
||||
export async function resetAllPricing() {
|
||||
const db = await getDb();
|
||||
db.data.pricing = {};
|
||||
await db.write();
|
||||
return db.data.pricing;
|
||||
}
|
||||
|
||||
|
||||
+71
-2
@@ -229,6 +229,62 @@ export async function getRecentLogs(limit = 200) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate cost for a usage entry
|
||||
* @param {string} provider - Provider ID
|
||||
* @param {string} model - Model ID
|
||||
* @param {object} tokens - Token counts
|
||||
* @returns {number} Cost in dollars
|
||||
*/
|
||||
async function calculateCost(provider, model, tokens) {
|
||||
if (!tokens || !provider || !model) return 0;
|
||||
|
||||
try {
|
||||
const { getPricingForModel } = await import("@/lib/localDb.js");
|
||||
const pricing = await getPricingForModel(provider, model);
|
||||
|
||||
if (!pricing) return 0;
|
||||
|
||||
let cost = 0;
|
||||
|
||||
// Input tokens (non-cached)
|
||||
const inputTokens = tokens.prompt_tokens || tokens.input_tokens || 0;
|
||||
const cachedTokens = tokens.cached_tokens || tokens.cache_read_input_tokens || 0;
|
||||
const nonCachedInput = Math.max(0, inputTokens - cachedTokens);
|
||||
|
||||
cost += (nonCachedInput * (pricing.input / 1000000));
|
||||
|
||||
// Cached tokens
|
||||
if (cachedTokens > 0) {
|
||||
const cachedRate = pricing.cached || pricing.input; // Fallback to input rate
|
||||
cost += (cachedTokens * (cachedRate / 1000000));
|
||||
}
|
||||
|
||||
// Output tokens
|
||||
const outputTokens = tokens.completion_tokens || tokens.output_tokens || 0;
|
||||
cost += (outputTokens * (pricing.output / 1000000));
|
||||
|
||||
// Reasoning tokens
|
||||
const reasoningTokens = tokens.reasoning_tokens || 0;
|
||||
if (reasoningTokens > 0) {
|
||||
const reasoningRate = pricing.reasoning || pricing.output; // Fallback to output rate
|
||||
cost += (reasoningTokens * (reasoningRate / 1000000));
|
||||
}
|
||||
|
||||
// Cache creation tokens
|
||||
const cacheCreationTokens = tokens.cache_creation_input_tokens || 0;
|
||||
if (cacheCreationTokens > 0) {
|
||||
const cacheCreationRate = pricing.cache_creation || pricing.input; // Fallback to input rate
|
||||
cost += (cacheCreationTokens * (cacheCreationRate / 1000000));
|
||||
}
|
||||
|
||||
return cost;
|
||||
} catch (error) {
|
||||
console.error("Error calculating cost:", error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get aggregated usage stats
|
||||
*/
|
||||
@@ -258,6 +314,7 @@ export async function getUsageStats() {
|
||||
totalRequests: history.length,
|
||||
totalPromptTokens: 0,
|
||||
totalCompletionTokens: 0,
|
||||
totalCost: 0, // NEW
|
||||
byProvider: {},
|
||||
byModel: {},
|
||||
byAccount: {},
|
||||
@@ -300,7 +357,8 @@ export async function getUsageStats() {
|
||||
bucketMap[bucketKey] = {
|
||||
requests: 0,
|
||||
promptTokens: 0,
|
||||
completionTokens: 0
|
||||
completionTokens: 0,
|
||||
cost: 0
|
||||
};
|
||||
stats.last10Minutes.push(bucketMap[bucketKey]);
|
||||
}
|
||||
@@ -310,8 +368,12 @@ export async function getUsageStats() {
|
||||
const completionTokens = entry.tokens?.completion_tokens || 0;
|
||||
const entryTime = new Date(entry.timestamp);
|
||||
|
||||
// Calculate cost for this entry
|
||||
const entryCost = await calculateCost(entry.provider, entry.model, entry.tokens);
|
||||
|
||||
stats.totalPromptTokens += promptTokens;
|
||||
stats.totalCompletionTokens += completionTokens;
|
||||
stats.totalCost += entryCost;
|
||||
|
||||
// Last 10 minutes aggregation - floor entry time to its minute
|
||||
if (entryTime >= tenMinutesAgo && entryTime <= now) {
|
||||
@@ -320,6 +382,7 @@ export async function getUsageStats() {
|
||||
bucketMap[entryMinuteStart].requests++;
|
||||
bucketMap[entryMinuteStart].promptTokens += promptTokens;
|
||||
bucketMap[entryMinuteStart].completionTokens += completionTokens;
|
||||
bucketMap[entryMinuteStart].cost += entryCost;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -328,12 +391,14 @@ export async function getUsageStats() {
|
||||
stats.byProvider[entry.provider] = {
|
||||
requests: 0,
|
||||
promptTokens: 0,
|
||||
completionTokens: 0
|
||||
completionTokens: 0,
|
||||
cost: 0
|
||||
};
|
||||
}
|
||||
stats.byProvider[entry.provider].requests++;
|
||||
stats.byProvider[entry.provider].promptTokens += promptTokens;
|
||||
stats.byProvider[entry.provider].completionTokens += completionTokens;
|
||||
stats.byProvider[entry.provider].cost += entryCost;
|
||||
|
||||
// By Model
|
||||
// Format: "modelName (provider)" if provider is known
|
||||
@@ -344,6 +409,7 @@ export async function getUsageStats() {
|
||||
requests: 0,
|
||||
promptTokens: 0,
|
||||
completionTokens: 0,
|
||||
cost: 0,
|
||||
rawModel: entry.model,
|
||||
provider: entry.provider,
|
||||
lastUsed: entry.timestamp
|
||||
@@ -352,6 +418,7 @@ export async function getUsageStats() {
|
||||
stats.byModel[modelKey].requests++;
|
||||
stats.byModel[modelKey].promptTokens += promptTokens;
|
||||
stats.byModel[modelKey].completionTokens += completionTokens;
|
||||
stats.byModel[modelKey].cost += entryCost;
|
||||
if (new Date(entry.timestamp) > new Date(stats.byModel[modelKey].lastUsed)) {
|
||||
stats.byModel[modelKey].lastUsed = entry.timestamp;
|
||||
}
|
||||
@@ -367,6 +434,7 @@ export async function getUsageStats() {
|
||||
requests: 0,
|
||||
promptTokens: 0,
|
||||
completionTokens: 0,
|
||||
cost: 0,
|
||||
rawModel: entry.model,
|
||||
provider: entry.provider,
|
||||
connectionId: entry.connectionId,
|
||||
@@ -377,6 +445,7 @@ export async function getUsageStats() {
|
||||
stats.byAccount[accountKey].requests++;
|
||||
stats.byAccount[accountKey].promptTokens += promptTokens;
|
||||
stats.byAccount[accountKey].completionTokens += completionTokens;
|
||||
stats.byAccount[accountKey].cost += entryCost;
|
||||
if (new Date(entry.timestamp) > new Date(stats.byAccount[accountKey].lastUsed)) {
|
||||
stats.byAccount[accountKey].lastUsed = entry.timestamp;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user