Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 26 additions & 5 deletions src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,27 @@ function createCurrencyFormatter(locale: string) {
});
}

function formatCurrencyCost(locale: string, value: number): string {
const numericValue = Number(value || 0);
if (!Number.isFinite(numericValue) || numericValue === 0) {
return new Intl.NumberFormat(locale, {
style: "currency",
currency: "USD",
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(0);
}
Comment on lines +117 to +123

const absValue = Math.abs(numericValue);
const fractionDigits = absValue < 0.01 ? 6 : absValue < 1 ? 4 : 2;
return new Intl.NumberFormat(locale, {
style: "currency",
currency: "USD",
minimumFractionDigits: fractionDigits,
maximumFractionDigits: fractionDigits,
}).format(numericValue);
Comment on lines +127 to +132
}
Comment on lines +114 to +133

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Creating a new Intl.NumberFormat instance on every call to formatCurrencyCost is inefficient, as this function is executed multiple times during component rendering and Intl.NumberFormat instantiation is computationally expensive. Consider caching the formatters for the different precision levels (2, 4, and 6 digits) to improve performance.

const currencyFormatters = new Map<string, Intl.NumberFormat>();

function getCurrencyFormatter(locale: string, digits: number) {
  const key = locale + "-" + digits;
  let formatter = currencyFormatters.get(key);
  if (!formatter) {
    formatter = new Intl.NumberFormat(locale, {
      style: "currency",
      currency: "USD",
      minimumFractionDigits: digits,
      maximumFractionDigits: digits,
    });
    currencyFormatters.set(key, formatter);
  }
  return formatter;
}

function formatCurrencyCost(locale: string, value: number): string {
  const numericValue = Number(value || 0);
  if (!Number.isFinite(numericValue) || numericValue === 0) {
    return getCurrencyFormatter(locale, 2).format(0);
  }

  const absValue = Math.abs(numericValue);
  const fractionDigits = absValue < 0.01 ? 6 : absValue < 1 ? 4 : 2;
  return getCurrencyFormatter(locale, fractionDigits).format(numericValue);
}


function csvCell(value: string | number): string {
const text = String(value);
return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
Expand Down Expand Up @@ -398,25 +419,25 @@ export default function CostOverviewTab() {
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<MetricCard
label={t("spendToday")}
value={currencyFormatter.format(presetCosts["1d"] || 0)}
value={formatCurrencyCost(locale, presetCosts["1d"] || 0)}
loading={summaryLoading}
color="text-emerald-400"
/>
<MetricCard
label={t("spend7d")}
value={currencyFormatter.format(presetCosts["7d"] || 0)}
value={formatCurrencyCost(locale, presetCosts["7d"] || 0)}
loading={summaryLoading}
color="text-sky-400"
/>
<MetricCard
label={t("spend30d")}
value={currencyFormatter.format(presetCosts["30d"] || 0)}
value={formatCurrencyCost(locale, presetCosts["30d"] || 0)}
loading={summaryLoading}
color="text-violet-400"
/>
<MetricCard
label={t("selectedWindow")}
value={currencyFormatter.format(summary.totalCost || 0)}
value={formatCurrencyCost(locale, summary.totalCost || 0)}
subValue={selectedRangeLabel}
color="text-amber-400"
/>
Expand All @@ -438,7 +459,7 @@ export default function CostOverviewTab() {
/>
<CompactMetric
label={t("avgCostPerRequest")}
value={currencyFormatter.format(avgCostPerRequest)}
value={formatCurrencyCost(locale, avgCostPerRequest)}
/>
</div>
</Card>
Expand Down
76 changes: 59 additions & 17 deletions src/app/api/usage/analytics/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,44 @@ function findKeyInsensitive(obj: Record<string, any> | undefined | null, key: st
return obj[key.toLowerCase()];
}

function uniqueValues(values: Array<string | null | undefined>): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (const value of values) {
const normalized = typeof value === "string" ? value.trim().toLowerCase() : "";
if (!normalized || seen.has(normalized)) continue;
seen.add(normalized);
result.push(normalized);
}
return result;
}

function stripCodexEffortSuffix(model: string): string {
return model.replace(/-(?:xhigh|high|medium|low|none)$/i, "");
}

function getPricingModelCandidates(
model: string,
normalizeModelName: (model: string) => string
): string[] {
const normalizedModel = normalizeModelName(model);
const lowerModel = model.toLowerCase();
const lowerNormalized = normalizedModel.toLowerCase();
const hyphenModel = lowerModel.replace(/\./g, "-");
const hyphenNormalized = lowerNormalized.replace(/\./g, "-");
const effortBaseModel = stripCodexEffortSuffix(lowerNormalized);

return uniqueValues([
lowerModel,
lowerNormalized,
hyphenModel,
hyphenNormalized,
effortBaseModel,
effortBaseModel.replace(/\./g, "-"),
lowerNormalized === "codex-auto-review" ? "gpt-5.5" : null,
]);
}

function resolveModelPricing(
pricingByProvider: PricingByProvider,
providerAliasMap: Record<string, string>,
Expand Down Expand Up @@ -91,22 +129,15 @@ function resolveModelPricing(
if (pLower === "antigravity") providerPricing = findKeyInsensitive(pricingByProvider, "ag");
}

const normalizedModel = normalizeModelName(model).toLowerCase();
const shortModel = normalizedModel; // normalizeModelName behaves exactly like shortModelName
const hyphenModel = model.toLowerCase().replace(/\./g, "-");
const hyphenNormalized = normalizedModel.replace(/\./g, "-");
const lowerModel = model.toLowerCase();
const modelCandidates = getPricingModelCandidates(model, normalizeModelName);

const tryFind = (prov: Record<string, unknown> | null | undefined) => {
if (!prov || typeof prov !== "object") return null;
return (
findKeyInsensitive(prov as Record<string, unknown>, lowerModel) ||
findKeyInsensitive(prov as Record<string, unknown>, normalizedModel) ||
findKeyInsensitive(prov as Record<string, unknown>, shortModel) ||
findKeyInsensitive(prov as Record<string, unknown>, hyphenModel) ||
findKeyInsensitive(prov as Record<string, unknown>, hyphenNormalized) ||
null
);
for (const candidate of modelCandidates) {
const pricing = findKeyInsensitive(prov as Record<string, unknown>, candidate);
if (pricing) return pricing;
}
return null;
};

let pricing = providerPricing ? tryFind(providerPricing) : null;
Expand Down Expand Up @@ -478,10 +509,20 @@ export async function GET(request: Request) {
COUNT(*) as total,
SUM(CASE WHEN requested_model IS NOT NULL AND requested_model != '' THEN 1 ELSE 0 END) as with_requested,
SUM(CASE
WHEN requested_model IS NOT NULL
WHEN (combo_name IS NULL OR combo_name = '')
AND requested_model IS NOT NULL
AND requested_model != ''
AND model IS NOT NULL
AND model != ''
THEN 1 ELSE 0 END
) as fallback_eligible,
SUM(CASE
WHEN (combo_name IS NULL OR combo_name = '')
AND requested_model IS NOT NULL
AND requested_model != ''
AND model IS NOT NULL
AND requested_model != model
AND model != ''
AND LOWER(requested_model) != LOWER(model)
THEN 1 ELSE 0 END
) as fallbacks
FROM call_logs
Expand Down Expand Up @@ -515,10 +556,11 @@ export async function GET(request: Request) {
lastRequest: summaryRow?.lastRequest || "",
fallbackCount: Number(fallbackRow?.fallbacks || 0),
fallbackRatePct:
Number(fallbackRow?.with_requested || 0) > 0
Number(fallbackRow?.fallback_eligible || 0) > 0
? Number(
(
(Number(fallbackRow?.fallbacks || 0) / Number(fallbackRow?.with_requested || 1)) *
(Number(fallbackRow?.fallbacks || 0) /
Number(fallbackRow?.fallback_eligible || 1)) *
100
).toFixed(2)
)
Expand Down
1 change: 1 addition & 0 deletions src/shared/constants/pricing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ export const DEFAULT_PRICING = {

// OpenAI Codex (cx)
cx: {
"codex-auto-review": GPT_5_5_PRICING,
// GPT 5.5
"gpt-5.5": GPT_5_5_PRICING,
"gpt5.5": GPT_5_5_PRICING,
Expand Down
68 changes: 68 additions & 0 deletions tests/unit/usage-analytics-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,74 @@ test("GET /api/usage/analytics includes byModel array with cost calculations", a
assert.ok(gptEntry.cost > 0);
});

test("GET /api/usage/analytics resolves Codex GPT-5.5 pricing through provider aliases", async () => {
const db = core.getDbInstance();
db.prepare(
`INSERT INTO usage_history (provider, model, connection_id, tokens_input, tokens_output, success, latency_ms, timestamp)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
).run("codex", "gpt-5.5", "codex-conn", 1000, 500, 1, 250, new Date().toISOString());

const response = await analyticsRoute.GET(makeRequest("http://localhost/api/usage/analytics"));
const body = await response.json();

assert.equal(response.status, 200);
assertClose(body.summary.totalCost, 0.02);
assert.equal(body.byProvider[0].provider, "codex");
assertClose(body.byProvider[0].cost, 0.02);
assert.equal(body.byModel[0].model, "gpt-5.5");
assertClose(body.byModel[0].cost, 0.02);
});

test("GET /api/usage/analytics maps Codex auto-review usage to GPT-5.5 pricing", async () => {
const db = core.getDbInstance();
db.prepare(
`INSERT INTO usage_history (provider, model, connection_id, tokens_input, tokens_output, success, latency_ms, timestamp)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
).run(
"codex",
"codex-auto-review",
"codex-conn",
1000,
500,
1,
250,
new Date().toISOString()
);

const response = await analyticsRoute.GET(makeRequest("http://localhost/api/usage/analytics"));
const body = await response.json();

assert.equal(response.status, 200);
assertClose(body.summary.totalCost, 0.02);
assert.equal(body.byModel[0].model, "codex-auto-review");
assertClose(body.byModel[0].cost, 0.02);
});

test("GET /api/usage/analytics ignores normal combo routing in fallback statistics", async () => {
const db = core.getDbInstance();
const timestamp = new Date().toISOString();
db.prepare(
`INSERT INTO usage_history (provider, model, connection_id, tokens_input, tokens_output, success, latency_ms, timestamp)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
).run("codex", "gpt-5.5", "codex-conn", 1000, 500, 1, 250, timestamp);
db.prepare(
`INSERT INTO call_logs (id, provider, model, requested_model, combo_name, connection_id, timestamp)
VALUES (?, ?, ?, ?, ?, ?, ?)`
).run("combo-call", "codex", "gpt-5.5", "combo/dev", "dev", "codex-conn", timestamp);
db.prepare(
`INSERT INTO call_logs (id, provider, model, requested_model, connection_id, timestamp)
VALUES (?, ?, ?, ?, ?, ?)`
).run("same-model-call", "codex", "GPT-5.5", "gpt-5.5", "codex-conn", timestamp);

const response = await analyticsRoute.GET(makeRequest("http://localhost/api/usage/analytics"));
const body = await response.json();

assert.equal(response.status, 200);
assert.equal(body.summary.fallbackCount, 0);
assert.equal(body.summary.fallbackRatePct, 0);
assert.equal(body.summary.requestedModelCoveragePct, 100);
});

test("GET /api/usage/analytics filters by range parameter", async () => {
await seedAnalyticsData();

Expand Down