feat: add token breakdown helpers
This commit is contained in:
@@ -20,4 +20,16 @@ describe("sortDetailBreakdown", () => {
|
|||||||
|
|
||||||
expect(sorted.map((row) => row.name)).toEqual(["gamma", "alpha", "beta"]);
|
expect(sorted.map((row) => row.name)).toEqual(["gamma", "alpha", "beta"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("preserves subtype fields while sorting breakdown rows", () => {
|
||||||
|
const rows = [
|
||||||
|
{ name: "beta", calls: 2, total_tokens: 100, quota: 500, models: [{ name: "m2", calls: 1, total_tokens: 20, quota: 10 }] },
|
||||||
|
{ name: "alpha", calls: 5, total_tokens: 200, quota: 300, models: [{ name: "m1", calls: 1, total_tokens: 30, quota: 20 }] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const sorted = sortDetailBreakdown(rows, "total_tokens", false);
|
||||||
|
|
||||||
|
expect(sorted[0].models[0].name).toBe("m1");
|
||||||
|
expect(rows[0].models[0].name).toBe("m2");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,11 +7,11 @@ export interface DetailBreakdownItem {
|
|||||||
|
|
||||||
export type DetailBreakdownSortKey = "calls" | "total_tokens" | "quota";
|
export type DetailBreakdownSortKey = "calls" | "total_tokens" | "quota";
|
||||||
|
|
||||||
export function sortDetailBreakdown(
|
export function sortDetailBreakdown<T extends DetailBreakdownItem>(
|
||||||
items: DetailBreakdownItem[],
|
items: T[],
|
||||||
sortKey: DetailBreakdownSortKey,
|
sortKey: DetailBreakdownSortKey,
|
||||||
sortAsc: boolean
|
sortAsc: boolean
|
||||||
): DetailBreakdownItem[] {
|
): T[] {
|
||||||
return [...items].sort((a, b) => {
|
return [...items].sort((a, b) => {
|
||||||
const diff = a[sortKey] - b[sortKey];
|
const diff = a[sortKey] - b[sortKey];
|
||||||
if (diff !== 0) return sortAsc ? diff : -diff;
|
if (diff !== 0) return sortAsc ? diff : -diff;
|
||||||
|
|||||||
66
lib/token-breakdown.test.ts
Normal file
66
lib/token-breakdown.test.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import {
|
||||||
|
getPrimaryModelNames,
|
||||||
|
getSharePercent,
|
||||||
|
getTokenDisplayName,
|
||||||
|
getTokenRowKey,
|
||||||
|
shouldShowTokenTab,
|
||||||
|
sortTokenBreakdown,
|
||||||
|
} from "./token-breakdown";
|
||||||
|
|
||||||
|
describe("token breakdown helpers", () => {
|
||||||
|
const rows = [
|
||||||
|
{
|
||||||
|
name: "beta",
|
||||||
|
calls: 2,
|
||||||
|
total_tokens: 100,
|
||||||
|
quota: 500,
|
||||||
|
models: [{ name: "gpt-4o", calls: 1, total_tokens: 70, quota: 300 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "alpha",
|
||||||
|
calls: 5,
|
||||||
|
total_tokens: 300,
|
||||||
|
quota: 300,
|
||||||
|
models: [{ name: "claude-sonnet-4", calls: 1, total_tokens: 200, quota: 200 }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
test("sorts token rows without mutating nested model data", () => {
|
||||||
|
const sorted = sortTokenBreakdown(rows, "total_tokens", false);
|
||||||
|
|
||||||
|
expect(sorted.map((row) => row.name)).toEqual(["alpha", "beta"]);
|
||||||
|
expect(sorted[0].models[0].name).toBe("claude-sonnet-4");
|
||||||
|
expect(rows.map((row) => row.name)).toEqual(["beta", "alpha"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses a localized label for unnamed tokens", () => {
|
||||||
|
expect(getTokenDisplayName("", "未命名令牌")).toBe("未命名令牌");
|
||||||
|
expect(getTokenDisplayName("prod-key", "未命名令牌")).toBe("prod-key");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps unnamed token row keys stable", () => {
|
||||||
|
expect(getTokenRowKey("")).toBe("__unnamed_token__");
|
||||||
|
expect(getTokenRowKey("prod-key")).toBe("prod-key");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows the token tab only for user details", () => {
|
||||||
|
expect(shouldShowTokenTab("user")).toBe(true);
|
||||||
|
expect(shouldShowTokenTab("model")).toBe(false);
|
||||||
|
expect(shouldShowTokenTab("channel")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("calculates share percentages and handles empty totals", () => {
|
||||||
|
expect(getSharePercent(25, 100)).toBe(25);
|
||||||
|
expect(getSharePercent(25, 0)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("formats a compact primary model preview", () => {
|
||||||
|
expect(getPrimaryModelNames([
|
||||||
|
{ name: "a", calls: 1, total_tokens: 1, quota: 1 },
|
||||||
|
{ name: "b", calls: 1, total_tokens: 1, quota: 1 },
|
||||||
|
{ name: "c", calls: 1, total_tokens: 1, quota: 1 },
|
||||||
|
{ name: "d", calls: 1, total_tokens: 1, quota: 1 },
|
||||||
|
])).toBe("a, b, c");
|
||||||
|
});
|
||||||
|
});
|
||||||
35
lib/token-breakdown.ts
Normal file
35
lib/token-breakdown.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { sortDetailBreakdown, type DetailBreakdownItem, type DetailBreakdownSortKey } from "./detail-sort";
|
||||||
|
|
||||||
|
export interface TokenBreakdownItem extends DetailBreakdownItem {
|
||||||
|
models: DetailBreakdownItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TokenBreakdownSortKey = DetailBreakdownSortKey;
|
||||||
|
|
||||||
|
export function sortTokenBreakdown(
|
||||||
|
items: TokenBreakdownItem[],
|
||||||
|
sortKey: TokenBreakdownSortKey,
|
||||||
|
sortAsc: boolean
|
||||||
|
): TokenBreakdownItem[] {
|
||||||
|
return sortDetailBreakdown(items, sortKey, sortAsc);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTokenDisplayName(name: string, unnamedLabel: string): string {
|
||||||
|
return name === "" ? unnamedLabel : name;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTokenRowKey(name: string): string {
|
||||||
|
return name === "" ? "__unnamed_token__" : name;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldShowTokenTab(detailType: string): boolean {
|
||||||
|
return detailType === "user";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSharePercent(part: number, total: number): number {
|
||||||
|
return total > 0 ? (part / total) * 100 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPrimaryModelNames(models: DetailBreakdownItem[], limit = 3): string {
|
||||||
|
return models.slice(0, limit).map((model) => model.name || "(unknown)").join(", ");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user