From eb92e626d267089fe8ac6233a806d35da08d77f3 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Fri, 17 Jul 2026 06:11:43 -0300 Subject: [PATCH] fix(api): resolve provider display name and dedup byModel on normalized key (#7534, #7535) (#7573) - byProvider now resolves the internal provider id to its configured display name via getProviderById() (fallback: raw id for providers not in the static registry). Fixes the Usage page showing "codex" instead of "OpenAI Codex". - byModel's in-memory dedup key now uses the normalized model name instead of the raw one, so the same logical model recorded under a bare and a provider-prefixed spelling (e.g. "glm-5.2" vs "z-ai/glm-5.2") merges into a single aggregated row instead of appearing twice with the same displayed name. - Introduces a local UsageRows type alias in route.ts to shrink the repeated "as Array>" casts back under the frozen file-size baseline once the file was touched. --- .../fixes/7534-usage-provider-display-name.md | 1 + .../7535-usage-model-dedup-normalized-key.md | 1 + src/app/api/usage/analytics/route.ts | 48 +++++----- .../usage-analytics-model-dedup-7535.test.ts | 82 +++++++++++++++++ ...alytics-provider-display-name-7534.test.ts | 89 +++++++++++++++++++ tests/unit/usage-analytics-route.test.ts | 2 +- 6 files changed, 198 insertions(+), 25 deletions(-) create mode 100644 changelog.d/fixes/7534-usage-provider-display-name.md create mode 100644 changelog.d/fixes/7535-usage-model-dedup-normalized-key.md create mode 100644 tests/unit/usage-analytics-model-dedup-7535.test.ts create mode 100644 tests/unit/usage-analytics-provider-display-name-7534.test.ts diff --git a/changelog.d/fixes/7534-usage-provider-display-name.md b/changelog.d/fixes/7534-usage-provider-display-name.md new file mode 100644 index 0000000000..2354f914cf --- /dev/null +++ b/changelog.d/fixes/7534-usage-provider-display-name.md @@ -0,0 +1 @@ +- fix(api): Usage page "by provider" table now shows the configured provider display name (e.g. "OpenAI Codex") instead of the raw internal provider id (e.g. "codex") (#7534) diff --git a/changelog.d/fixes/7535-usage-model-dedup-normalized-key.md b/changelog.d/fixes/7535-usage-model-dedup-normalized-key.md new file mode 100644 index 0000000000..77f6dd7ea7 --- /dev/null +++ b/changelog.d/fixes/7535-usage-model-dedup-normalized-key.md @@ -0,0 +1 @@ +- fix(api): Usage page "model usage" table no longer lists the same logical model twice when it was recorded under both a bare and a provider-prefixed spelling (e.g. `glm-5.2` and `z-ai/glm-5.2`) — the in-memory dedup key now uses the normalized model name (#7535) diff --git a/src/app/api/usage/analytics/route.ts b/src/app/api/usage/analytics/route.ts index 01e0bed7af..beac6d426f 100644 --- a/src/app/api/usage/analytics/route.ts +++ b/src/app/api/usage/analytics/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +import { getProviderById } from "@/shared/constants/providers"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; import { getApiKeys } from "@/lib/db/apiKeys"; import { getUserDatabaseSettings } from "@/lib/db/databaseSettings"; @@ -54,6 +55,7 @@ function getRangeStartIso(range: string): string | null { const WEEKDAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; type PricingByProvider = Record>>; +type UsageRows = Array>; type ComputeCostFromPricing = ( pricing: Record | null | undefined, tokens: Record | null | undefined, @@ -413,9 +415,8 @@ export async function GET(request: Request) { const summaryRow = getUsageSummary(unifiedSource, unifiedParams) as Record; - const dailyRows = getDailyUsage(unifiedSource, unifiedParams) as Array>; - - const dailyCostRows = getDailyCostRows(unifiedSource, unifiedParams) as Array>; + const dailyRows = getDailyUsage(unifiedSource, unifiedParams) as UsageRows; + const dailyCostRows = getDailyCostRows(unifiedSource, unifiedParams) as UsageRows; const heatmapStart = new Date(); heatmapStart.setUTCDate(heatmapStart.getUTCDate() - 364); @@ -437,30 +438,30 @@ export async function GET(request: Request) { }); } - const heatmapRows = getHeatmapRows(heatmapConditions, heatmapParams) as Array>; + const heatmapRows = getHeatmapRows(heatmapConditions, heatmapParams) as UsageRows; - const modelRows = getModelUsageRows(unifiedSource, unifiedParams) as Array>; + const modelRows = getModelUsageRows(unifiedSource, unifiedParams) as UsageRows; - const providerCostRows = getProviderCostRows(unifiedSource, unifiedParams) as Array>; + const providerCostRows = getProviderCostRows(unifiedSource, unifiedParams) as UsageRows; - const providerRows = getProviderUsageRows(unifiedSource, unifiedParams) as Array>; + const providerRows = getProviderUsageRows(unifiedSource, unifiedParams) as UsageRows; const accountCostWhereClause = whereClause .replace(/timestamp/g, "usage_history.timestamp") .replace(/api_key_/g, "usage_history.api_key_"); - const accountCostRows = getAccountCostRows(accountCostWhereClause, params) as Array>; + const accountCostRows = getAccountCostRows(accountCostWhereClause, params) as UsageRows; - const accountRows = getAccountUsageRows(accountCostWhereClause, params) as Array>; + const accountRows = getAccountUsageRows(accountCostWhereClause, params) as UsageRows; const apiKeyWhereClause = appendWhereCondition( whereClause, "(api_key_id IS NOT NULL AND api_key_id != '') OR (api_key_name IS NOT NULL AND api_key_name != '')" ); - const apiKeyRows = getApiKeyUsageRows(apiKeyWhereClause, params) as Array>; + const apiKeyRows = getApiKeyUsageRows(apiKeyWhereClause, params) as UsageRows; - const serviceTierRows = getServiceTierUsageRows(unifiedSource, unifiedParams) as Array>; + const serviceTierRows = getServiceTierUsageRows(unifiedSource, unifiedParams) as UsageRows; - const apiKeyMetadataRows = getApiKeyMetadataRows(apiKeyWhereClause, params) as Array>; + const apiKeyMetadataRows = getApiKeyMetadataRows(apiKeyWhereClause, params) as UsageRows; const apiKeyMetadata = new Map }>(); for (const row of apiKeyMetadataRows) { @@ -477,7 +478,7 @@ export async function GET(request: Request) { apiKeyMetadata.set(groupKey, existing); } - const weeklyRows = getWeeklyPatternRows(unifiedSource, unifiedParams) as Array>; + const weeklyRows = getWeeklyPatternRows(unifiedSource, unifiedParams) as UsageRows; const fallbackRow = getFallbackStats(whereClause, params) as Record; @@ -590,7 +591,7 @@ export async function GET(request: Request) { normalizeModelName, computeCostFromPricing ); - const key = `${provider}::${model}`; + const key = `${provider}::${short}`; const existing = modelMap.get(key) || { model: short, provider, @@ -662,7 +663,7 @@ export async function GET(request: Request) { } const byProvider = providerRows.map((row) => ({ - provider: row.provider, + provider: getProviderById(toStringValue(row.provider))?.name ?? toStringValue(row.provider), requests: Number(row.requests), promptTokens: Number(row.promptTokens), completionTokens: Number(row.completionTokens), @@ -897,16 +898,15 @@ export async function GET(request: Request) { } const presetSinceIso = getRangeStartIso(presetRange); - const { unifiedSource: presetUnifiedSource, unifiedParams: presetParams } = - buildPresetUnifiedSource({ - sinceIso: presetSinceIso ?? null, - untilIso: null, - rawCutoffDate, - apiKeyWhere, - apiKeyParams: apiKeyParamEntries, - }); + const { unifiedSource: pSrc, unifiedParams: pParams } = buildPresetUnifiedSource({ + sinceIso: presetSinceIso ?? null, + untilIso: null, + rawCutoffDate, + apiKeyWhere, + apiKeyParams: apiKeyParamEntries, + }); - const presetModelRows = getPresetCostModelRows(presetUnifiedSource, presetParams) as Array>; + const presetModelRows = getPresetCostModelRows(pSrc, pParams) as UsageRows; let presetTotalCost = 0; for (const row of presetModelRows) { diff --git a/tests/unit/usage-analytics-model-dedup-7535.test.ts b/tests/unit/usage-analytics-model-dedup-7535.test.ts new file mode 100644 index 0000000000..5d55c19f4a --- /dev/null +++ b/tests/unit/usage-analytics-model-dedup-7535.test.ts @@ -0,0 +1,82 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync( + path.join(os.tmpdir(), "omniroute-usage-analytics-model-dedup-") +); +process.env.DATA_DIR = TEST_DATA_DIR; +const ORIGINAL_API_KEY_SECRET = process.env.API_KEY_SECRET; +process.env.API_KEY_SECRET = "test-usage-analytics-model-dedup-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); +const usageHistory = await import("../../src/lib/usage/usageHistory.ts"); +const analyticsRoute = await import("../../src/app/api/usage/analytics/route.ts"); +const { normalizeModelName } = await import("../../src/lib/usage/costCalculator.ts"); + +function makeRequest(url: string) { + return new Request(url, { method: "GET" }); +} + +test.beforeEach(() => { + core.resetDbInstance(); + apiKeysDb.resetApiKeyState(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); + usageHistory.clearPendingRequests(); +}); + +test.after(() => { + core.resetDbInstance(); + apiKeysDb.resetApiKeyState(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + + if (ORIGINAL_API_KEY_SECRET === undefined) { + delete process.env.API_KEY_SECRET; + } else { + process.env.API_KEY_SECRET = ORIGINAL_API_KEY_SECRET; + } +}); + +test("#7535: byModel must not list the same logical model twice under one raw/one prefixed id", async () => { + const db = core.getDbInstance(); + const now = new Date(); + + db.prepare( + `INSERT INTO usage_history (provider, model, connection_id, api_key_id, api_key_name, tokens_input, tokens_output, success, latency_ms, timestamp) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run("zai", "glm-5.2", "test-conn", "test-key", "Primary Key", 100, 50, 1, 200, now.toISOString()); + db.prepare( + `INSERT INTO usage_history (provider, model, connection_id, api_key_id, api_key_name, tokens_input, tokens_output, success, latency_ms, timestamp) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + "zai", + "z-ai/glm-5.2", + "test-conn", + "test-key", + "Primary Key", + 80, + 40, + 1, + 150, + new Date(now.getTime() - 60_000).toISOString() + ); + + assert.equal(normalizeModelName("glm-5.2"), "glm-5.2"); + assert.equal(normalizeModelName("z-ai/glm-5.2"), "glm-5.2"); + + const response = await analyticsRoute.GET(makeRequest("http://localhost/api/usage/analytics")); + const body = await response.json(); + + assert.equal(response.status, 200); + const glmEntries = body.byModel.filter((row: { model: string }) => row.model === "glm-5.2"); + assert.equal( + glmEntries.length, + 1, + `expected exactly one "glm-5.2" row in byModel, got ${glmEntries.length}: ${JSON.stringify(glmEntries)} (#7535)` + ); + assert.equal(glmEntries[0].requests, 2, "the two raw spellings should merge into one aggregated row"); +}); diff --git a/tests/unit/usage-analytics-provider-display-name-7534.test.ts b/tests/unit/usage-analytics-provider-display-name-7534.test.ts new file mode 100644 index 0000000000..e060f7eb58 --- /dev/null +++ b/tests/unit/usage-analytics-provider-display-name-7534.test.ts @@ -0,0 +1,89 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync( + path.join(os.tmpdir(), "omniroute-usage-analytics-provider-name-") +); +process.env.DATA_DIR = TEST_DATA_DIR; +const ORIGINAL_API_KEY_SECRET = process.env.API_KEY_SECRET; +process.env.API_KEY_SECRET = "test-usage-analytics-provider-name-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); +const usageHistory = await import("../../src/lib/usage/usageHistory.ts"); +const analyticsRoute = await import("../../src/app/api/usage/analytics/route.ts"); +const providers = await import("../../src/shared/constants/providers.ts"); + +function makeRequest(url: string) { + return new Request(url, { method: "GET" }); +} + +test.beforeEach(() => { + core.resetDbInstance(); + apiKeysDb.resetApiKeyState(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); + usageHistory.clearPendingRequests(); +}); + +test.after(() => { + core.resetDbInstance(); + apiKeysDb.resetApiKeyState(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + + if (ORIGINAL_API_KEY_SECRET === undefined) { + delete process.env.API_KEY_SECRET; + } else { + process.env.API_KEY_SECRET = ORIGINAL_API_KEY_SECRET; + } +}); + +test("#7534: byProvider exposes the configured display name, not the raw internal provider id", async () => { + const db = core.getDbInstance(); + const now = new Date().toISOString(); + db.prepare( + `INSERT INTO usage_history (provider, model, connection_id, api_key_id, api_key_name, tokens_input, tokens_output, success, latency_ms, timestamp) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run("codex", "gpt-5.5", "test-conn", "test-key", "Primary Key", 100, 50, 1, 200, now); + + const response = await analyticsRoute.GET(makeRequest("http://localhost/api/usage/analytics")); + const body = await response.json(); + + const expectedDisplayName = providers.getProviderById("codex")?.name; + assert.equal(response.status, 200); + assert.equal( + body.byProvider[0].provider, + expectedDisplayName, + `expected byProvider[0].provider to be the display name "${expectedDisplayName}", ` + + `but got "${body.byProvider[0].provider}" (#7534)` + ); +}); + +test("#7534: byProvider falls back to the raw id for providers not in the static registry", async () => { + const db = core.getDbInstance(); + const now = new Date().toISOString(); + db.prepare( + `INSERT INTO usage_history (provider, model, connection_id, api_key_id, api_key_name, tokens_input, tokens_output, success, latency_ms, timestamp) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + "openai-compatible-custom", + "some-model", + "test-conn", + "test-key", + "Primary Key", + 100, + 50, + 1, + 200, + now + ); + + const response = await analyticsRoute.GET(makeRequest("http://localhost/api/usage/analytics")); + const body = await response.json(); + + assert.equal(response.status, 200); + assert.equal(body.byProvider[0].provider, "openai-compatible-custom"); +}); diff --git a/tests/unit/usage-analytics-route.test.ts b/tests/unit/usage-analytics-route.test.ts index a74e4e34f7..414fd9cd19 100644 --- a/tests/unit/usage-analytics-route.test.ts +++ b/tests/unit/usage-analytics-route.test.ts @@ -144,7 +144,7 @@ test("GET /api/usage/analytics resolves Codex GPT-5.5 pricing through provider a assert.equal(response.status, 200); assertClose(body.summary.totalCost, 0.02); - assert.equal(body.byProvider[0].provider, "codex"); + assert.equal(body.byProvider[0].provider, "OpenAI Codex"); assertClose(body.byProvider[0].cost, 0.02); assert.equal(body.byModel[0].model, "gpt-5.5"); assertClose(body.byModel[0].cost, 0.02);