fix(usage): preserve API key usage attribution in live stats
The 24h/today branch of getUsageStats keyed byApiKey buckets on the
masked key, while the daily rollup and the lastUsed overlay key on the
full key. Every key minted by one instance shares the sk-{machineId}
prefix, so the mask collapsed all of them into a single bucket and
attributed one key's usage to another.
Key the live branch by the full api key too; the masked value is still
carried on the bucket for display.
This commit is contained in:
@@ -634,7 +634,7 @@ export async function getUsageStats(period = "all") {
|
|||||||
const keyInfo = apiKeyMap[r.apiKey];
|
const keyInfo = apiKeyMap[r.apiKey];
|
||||||
const keyName = keyInfo?.name || r.apiKey.slice(0, 8) + "...";
|
const keyName = keyInfo?.name || r.apiKey.slice(0, 8) + "...";
|
||||||
const apiKeyMasked = maskApiKey(r.apiKey);
|
const apiKeyMasked = maskApiKey(r.apiKey);
|
||||||
const akKey = `${apiKeyMasked}|${r.model}|${r.provider || "unknown"}`;
|
const akKey = `${r.apiKey}|${r.model}|${r.provider || "unknown"}`;
|
||||||
if (!stats.byApiKey[akKey]) {
|
if (!stats.byApiKey[akKey]) {
|
||||||
stats.byApiKey[akKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cachedTokens: 0, cost: 0, rawModel: r.model, provider: providerDisplayName, apiKeyMasked, keyName, apiKeyKey: apiKeyMasked, lastUsed: r.timestamp };
|
stats.byApiKey[akKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cachedTokens: 0, cost: 0, rawModel: r.model, provider: providerDisplayName, apiKeyMasked, keyName, apiKeyKey: apiKeyMasked, lastUsed: r.timestamp };
|
||||||
}
|
}
|
||||||
|
|||||||
57
tests/unit/usage-api-key-attribution.test.js
Normal file
57
tests/unit/usage-api-key-attribution.test.js
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
|
|
||||||
|
let tempDir;
|
||||||
|
let db;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "9router-api-key-"));
|
||||||
|
process.env.DATA_DIR = tempDir;
|
||||||
|
vi.resetModules();
|
||||||
|
db = await import("@/lib/db/index.js");
|
||||||
|
await db.initDb();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete process.env.DATA_DIR;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Usage stats API key attribution", () => {
|
||||||
|
it("keeps API keys with the same masked prefix in separate buckets", async () => {
|
||||||
|
const apiKeyA = "sk-machine-aaaaaa-11111111";
|
||||||
|
const apiKeyB = "sk-machine-bbbbbb-22222222";
|
||||||
|
|
||||||
|
await db.saveRequestUsage({
|
||||||
|
provider: "openai",
|
||||||
|
model: "gpt-4",
|
||||||
|
connectionId: "c1",
|
||||||
|
apiKey: apiKeyA,
|
||||||
|
tokens: { prompt_tokens: 10, completion_tokens: 5 },
|
||||||
|
endpoint: "/v1/chat",
|
||||||
|
status: "ok",
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.saveRequestUsage({
|
||||||
|
provider: "openai",
|
||||||
|
model: "gpt-4",
|
||||||
|
connectionId: "c1",
|
||||||
|
apiKey: apiKeyB,
|
||||||
|
tokens: { prompt_tokens: 20, completion_tokens: 10 },
|
||||||
|
endpoint: "/v1/chat",
|
||||||
|
status: "ok",
|
||||||
|
});
|
||||||
|
|
||||||
|
const stats = await db.getUsageStats("24h");
|
||||||
|
const apiKeyEntries = Object.values(stats.byApiKey);
|
||||||
|
|
||||||
|
expect(apiKeyEntries).toHaveLength(2);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
apiKeyEntries
|
||||||
|
.map((entry) => entry.promptTokens)
|
||||||
|
.sort((a, b) => a - b)
|
||||||
|
).toEqual([10, 20]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user