fix(ui): stop daily_with_models export from overcounting keys shared across entities

This commit is contained in:
Devin AI 2026-07-09 06:23:06 +00:00
parent 142d5aa12b
commit 7b65185a68
3 changed files with 197 additions and 51 deletions

View file

@ -1,7 +1,7 @@
{
"@typescript-eslint/no-explicit-any": 1982,
"@typescript-eslint/no-explicit-any": 1979,
"complexity": 128,
"local/no-large-inline-object-arg": 512,
"local/no-large-inline-object-arg": 519,
"local/no-long-condition-chain": 233,
"max-depth": 59,
"no-console": 15

View file

@ -1205,6 +1205,10 @@ describe("EntityUsageExport utils", () => {
successful_requests: 10,
failed_requests: 0,
total_tokens: 700,
prompt_tokens: 500,
completion_tokens: 200,
cache_read_input_tokens: 300,
cache_creation_input_tokens: 120,
},
metadata: { team_id: "team-1" },
},
@ -1215,6 +1219,10 @@ describe("EntityUsageExport utils", () => {
successful_requests: 15,
failed_requests: 0,
total_tokens: 350,
prompt_tokens: 250,
completion_tokens: 100,
cache_read_input_tokens: 150,
cache_creation_input_tokens: 80,
},
metadata: { team_id: "team-1" },
},
@ -1645,6 +1653,132 @@ describe("EntityUsageExport utils", () => {
expect(result).toHaveLength(0);
});
it("should not overcount when a key is shared across entities (regression for #32581)", () => {
const data: EntitySpendData = {
results: [
{
date: "2025-04-01",
breakdown: {
entities: {
tagA: {
metrics: {
spend: 10.0,
api_requests: 100,
successful_requests: 100,
failed_requests: 0,
total_tokens: 1000,
prompt_tokens: 600,
completion_tokens: 400,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
api_key_breakdown: {
key1: {
metrics: {
spend: 10.0,
api_requests: 100,
successful_requests: 100,
failed_requests: 0,
total_tokens: 1000,
prompt_tokens: 600,
completion_tokens: 400,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
metadata: { team_id: "team-1" },
},
},
},
tagB: {
metrics: {
spend: 10.0,
api_requests: 100,
successful_requests: 100,
failed_requests: 0,
total_tokens: 1000,
prompt_tokens: 600,
completion_tokens: 400,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
api_key_breakdown: {
key1: {
metrics: {
spend: 10.0,
api_requests: 100,
successful_requests: 100,
failed_requests: 0,
total_tokens: 1000,
prompt_tokens: 600,
completion_tokens: 400,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
metadata: { team_id: "team-1" },
},
},
},
},
models: {
"gpt-4": {
metrics: {
spend: 20.0,
api_requests: 200,
successful_requests: 200,
failed_requests: 0,
total_tokens: 2000,
},
api_key_breakdown: {
key1: {
metrics: {
spend: 20.0,
api_requests: 200,
successful_requests: 200,
failed_requests: 0,
total_tokens: 2000,
prompt_tokens: 1200,
completion_tokens: 800,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
metadata: { team_id: "team-1" },
},
},
},
},
},
},
],
metadata: {
total_spend: 20.0,
total_api_requests: 200,
total_successful_requests: 200,
total_failed_requests: 0,
total_tokens: 2000,
},
};
const result = generateDailyWithModelsData(data, "Tag");
const tagA = result.find((r) => r["Tag ID"] === "tagA");
const tagB = result.find((r) => r["Tag ID"] === "tagB");
expect(tagA?.["Spend ($)"]).toBe("10.0000");
expect(tagA?.["Total Tokens"]).toBe(1000);
expect(tagA?.["Prompt Tokens"]).toBe(600);
expect(tagA?.Requests).toBe(100);
expect(tagB?.["Spend ($)"]).toBe("10.0000");
expect(tagB?.["Total Tokens"]).toBe(1000);
const totalSpend = result.reduce((sum, r) => sum + parseFloat(r["Spend ($)"].replace(/,/g, "")), 0);
const totalTokens = result.reduce((sum, r) => sum + r["Total Tokens"], 0);
const totalRequests = result.reduce((sum, r) => sum + r.Requests, 0);
expect(totalSpend).toBeCloseTo(20.0, 4);
expect(totalTokens).toBe(2000);
expect(totalRequests).toBe(200);
});
});
describe("generateExportData", () => {

View file

@ -232,6 +232,51 @@ export const generateDailyWithKeysData = (
return dailyKeyBreakdown.sort((a, b) => new Date(a.Date).getTime() - new Date(b.Date).getTime());
};
type MetricTotals = Record<string, number>;
type KeyEntry = { metrics?: MetricTotals };
type ModelEntry = { api_key_breakdown?: Record<string, KeyEntry> };
const emptyMetricTotals = (): MetricTotals => Object.fromEntries(METRIC_KEYS.map((metric) => [metric, 0]));
const sumKeyTotalsAcrossModels = (models: Record<string, ModelEntry>): Record<string, MetricTotals> => {
const keyTotals: Record<string, MetricTotals> = {};
Object.values(models).forEach((modelData) => {
Object.entries(modelData.api_key_breakdown || {}).forEach(([apiKey, keyData]) => {
const totals = keyTotals[apiKey] || (keyTotals[apiKey] = emptyMetricTotals());
const metrics = keyData.metrics || {};
METRIC_KEYS.forEach((metric) => {
totals[metric] += metrics[metric] || 0;
});
});
});
return keyTotals;
};
const aggregateEntityModelMetrics = (
entityApiKeys: Record<string, KeyEntry>,
models: Record<string, ModelEntry>,
keyTotalsAcrossModels: Record<string, MetricTotals>,
): Record<string, MetricTotals> => {
const entityModels: Record<string, MetricTotals> = {};
Object.entries(entityApiKeys).forEach(([apiKey, entityKeyData]) => {
const keyTotals = keyTotalsAcrossModels[apiKey];
if (!keyTotals) return;
const entityKeyMetrics = entityKeyData.metrics || {};
Object.entries(models).forEach(([model, modelData]) => {
const modelKeyMetrics = modelData.api_key_breakdown?.[apiKey]?.metrics;
if (!modelKeyMetrics) return;
const bucket = entityModels[model] || (entityModels[model] = emptyMetricTotals());
METRIC_KEYS.forEach((metric) => {
const keyTotal = keyTotals[metric];
const entityShare = keyTotal > 0 ? (entityKeyMetrics[metric] || 0) / keyTotal : 0;
bucket[metric] += (modelKeyMetrics[metric] || 0) * entityShare;
});
});
});
return entityModels;
};
export const generateDailyWithModelsData = (
spendData: EntitySpendData,
entityLabel: string,
@ -240,65 +285,32 @@ export const generateDailyWithModelsData = (
const dailyModelBreakdown: any[] = [];
spendData.results.forEach((day) => {
const dailyEntityModels: { [key: string]: { [key: string]: any } } = {};
const models = day.breakdown.models || {};
const keyTotalsAcrossModels = sumKeyTotalsAcrossModels(models);
Object.entries(resolveEntities(day.breakdown)).forEach(([entity, entityData]: [string, any]) => {
if (!dailyEntityModels[entity]) {
dailyEntityModels[entity] = {};
}
Object.entries(day.breakdown.models || {}).forEach(([model, modelData]: [string, any]) => {
const entityApiKeys = entityData.api_key_breakdown || {};
const modelApiKeys = modelData.api_key_breakdown || {};
Object.keys(entityApiKeys).forEach((apiKey) => {
const keyMetrics = modelApiKeys[apiKey]?.metrics;
if (!keyMetrics) return;
if (!dailyEntityModels[entity][model]) {
dailyEntityModels[entity][model] = {
spend: 0,
requests: 0,
successful: 0,
failed: 0,
tokens: 0,
promptTokens: 0,
completionTokens: 0,
cacheReadInputTokens: 0,
cacheCreationInputTokens: 0,
};
}
dailyEntityModels[entity][model].spend += keyMetrics.spend || 0;
dailyEntityModels[entity][model].requests += keyMetrics.api_requests || 0;
dailyEntityModels[entity][model].successful += keyMetrics.successful_requests || 0;
dailyEntityModels[entity][model].failed += keyMetrics.failed_requests || 0;
dailyEntityModels[entity][model].tokens += keyMetrics.total_tokens || 0;
dailyEntityModels[entity][model].promptTokens += keyMetrics.prompt_tokens || 0;
dailyEntityModels[entity][model].completionTokens += keyMetrics.completion_tokens || 0;
dailyEntityModels[entity][model].cacheReadInputTokens += keyMetrics.cache_read_input_tokens || 0;
dailyEntityModels[entity][model].cacheCreationInputTokens += keyMetrics.cache_creation_input_tokens || 0;
});
});
});
Object.entries(dailyEntityModels).forEach(([entity, models]) => {
const { id, alias } = resolveEntityDisplay(entity, teamAliasMap);
const entityModels = aggregateEntityModelMetrics(
entityData.api_key_breakdown || {},
models,
keyTotalsAcrossModels,
);
Object.entries(models).forEach(([model, metrics]: [string, any]) => {
Object.entries(entityModels).forEach(([model, metrics]) => {
dailyModelBreakdown.push({
Date: day.date,
[entityLabel]: alias,
[`${entityLabel} ID`]: id,
Model: model,
"Spend ($)": formatNumberWithCommas(metrics.spend, 4),
Requests: metrics.requests,
Successful: metrics.successful,
Failed: metrics.failed,
"Total Tokens": metrics.tokens,
"Prompt Tokens": metrics.promptTokens,
"Completion Tokens": metrics.completionTokens,
"Cache Read Input Tokens": metrics.cacheReadInputTokens,
"Cache Creation Input Tokens": metrics.cacheCreationInputTokens,
Requests: metrics.api_requests,
Successful: metrics.successful_requests,
Failed: metrics.failed_requests,
"Total Tokens": metrics.total_tokens,
"Prompt Tokens": metrics.prompt_tokens,
"Completion Tokens": metrics.completion_tokens,
"Cache Read Input Tokens": metrics.cache_read_input_tokens,
"Cache Creation Input Tokens": metrics.cache_creation_input_tokens,
});
});
});