From 9577a8d9e82b408a15fe8a43708aa946922db3b9 Mon Sep 17 00:00:00 2001 From: Jan Leon Date: Mon, 4 May 2026 14:39:48 +0200 Subject: [PATCH] feat: enhance cost formatting and add Codex GPT-5.5 pricing support (#1944) Integrated into release/v3.7.9 --- .../dashboard/costs/CostOverviewTab.tsx | 31 ++++++-- src/app/api/usage/analytics/route.ts | 76 ++++++++++++++----- src/shared/constants/pricing.ts | 1 + tests/unit/usage-analytics-route.test.ts | 68 +++++++++++++++++ 4 files changed, 154 insertions(+), 22 deletions(-) diff --git a/src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx b/src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx index a0a983ce44..d02a86b9d4 100644 --- a/src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx +++ b/src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx @@ -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); + } + + 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); +} + function csvCell(value: string | number): string { const text = String(value); return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text; @@ -398,25 +419,25 @@ export default function CostOverviewTab() {
@@ -438,7 +459,7 @@ export default function CostOverviewTab() { />
diff --git a/src/app/api/usage/analytics/route.ts b/src/app/api/usage/analytics/route.ts index e89d9351e2..d284e1c876 100644 --- a/src/app/api/usage/analytics/route.ts +++ b/src/app/api/usage/analytics/route.ts @@ -64,6 +64,44 @@ function findKeyInsensitive(obj: Record | undefined | null, key: st return obj[key.toLowerCase()]; } +function uniqueValues(values: Array): string[] { + const seen = new Set(); + 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, @@ -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 | null | undefined) => { if (!prov || typeof prov !== "object") return null; - return ( - findKeyInsensitive(prov as Record, lowerModel) || - findKeyInsensitive(prov as Record, normalizedModel) || - findKeyInsensitive(prov as Record, shortModel) || - findKeyInsensitive(prov as Record, hyphenModel) || - findKeyInsensitive(prov as Record, hyphenNormalized) || - null - ); + for (const candidate of modelCandidates) { + const pricing = findKeyInsensitive(prov as Record, candidate); + if (pricing) return pricing; + } + return null; }; let pricing = providerPricing ? tryFind(providerPricing) : null; @@ -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 requested_model != model + 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 model != '' + AND LOWER(requested_model) != LOWER(model) THEN 1 ELSE 0 END ) as fallbacks FROM call_logs @@ -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) ) diff --git a/src/shared/constants/pricing.ts b/src/shared/constants/pricing.ts index bbeaeeeeab..66baacfc2e 100644 --- a/src/shared/constants/pricing.ts +++ b/src/shared/constants/pricing.ts @@ -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, diff --git a/tests/unit/usage-analytics-route.test.ts b/tests/unit/usage-analytics-route.test.ts index 97127672d7..66935d5819 100644 --- a/tests/unit/usage-analytics-route.test.ts +++ b/tests/unit/usage-analytics-route.test.ts @@ -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();