feat: enhance cost formatting and add Codex GPT-5.5 pricing support (#1944)

Integrated into release/v3.7.9
This commit is contained in:
Jan Leon
2026-05-04 14:39:48 +02:00
committed by GitHub
parent 3f063e5c52
commit 9577a8d9e8
4 changed files with 154 additions and 22 deletions

View File

@@ -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() {
<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"
/>
@@ -438,7 +459,7 @@ export default function CostOverviewTab() {
/>
<CompactMetric
label={t("avgCostPerRequest")}
value={currencyFormatter.format(avgCostPerRequest)}
value={formatCurrencyCost(locale, avgCostPerRequest)}
/>
</div>
</Card>

View File

@@ -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>,
@@ -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;
@@ -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)
)

View File

@@ -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,

View File

@@ -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();