Skip to content

Commit 44dbe1b

Browse files
authored
Merge pull request #786 from icebear0828/fix/usage-chart-curve-integrity
Preserve usage chart curve integrity
2 parents cefc12b + 720d8f6 commit 44dbe1b

5 files changed

Lines changed: 87 additions & 29 deletions

File tree

README_EN.md

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -178,21 +178,21 @@ If you see streaming AI text, the setup is working. If you get 401, double-check
178178
│ POST /gemini/* (Gemini) │
179179
│ │ │
180180
│ ▼ │
181-
│ ┌──────────┐ ┌───────────────┐ ┌──────────────┐ │
182-
│ │ Routes │──▶│ Translation │──▶│ Proxy │ │
183-
│ │ (Hono) │ │ Multi→Codex │ │ Native TLS │ │
184-
│ └──────────┘ └───────────────┘ └──────┬───────┘ │
185-
│ ▲
186-
│ │ ┌───────────────┐
187-
│ └──────────│ Translation │◀─────────┘
188-
│ │ Codex→Multi │ SSE stream │
181+
│ ┌──────────┐ ┌───────────────┐ ┌──────────────┐
182+
│ │ Routes │──▶│ Translation │──▶│ Proxy │
183+
│ │ (Hono) │ │ Multi→Codex │ │ Native TLS │
184+
│ └──────────┘ └───────────────┘ └──────┬───────┘
185+
│ ▲
186+
│ │ ┌───────────────┐
187+
│ └──────────│ Translation │◀────────
188+
│ │ Codex→Multi │ SSE stream
189189
│ └───────────────┘ │
190190
│ │
191-
│ ┌──────────┐ ┌───────────────┐ ┌──────────────────┐ │
192-
│ │ Auth │ │ Fingerprint │ │ Model Store │ │
193-
│ │OAuth/API │ │ Rust (rustls) │ │ Static + Dynamic │ │
194-
│ │ API Keys │ │ Headers/UA │ │ Plan Routing │ │
195-
│ └──────────┘ └───────────────┘ └──────────────────┘ │
191+
│ ┌──────────┐ ┌───────────────┐ ┌──────────────────┐
192+
│ │ Auth │ │ Fingerprint │ │ Model Store │
193+
│ │OAuth/API │ │ Rust (rustls) │ │ Static + Dynamic │
194+
│ │ API Keys │ │ Headers/UA │ │ Plan Routing │
195+
│ └──────────┘ └───────────────┘ └──────────────────┘
196196
│ │
197197
└──────────────────────────────────────────────────────────┘
198198

src/auth/usage-stats.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -347,9 +347,10 @@ export class UsageStatsStore {
347347
granularity: "raw" | "five_min" | "hourly" | "daily",
348348
): UsageDataPoint[] {
349349
const cutoff = range === "all" ? null : Date.now() - range * 60 * 60 * 1000;
350-
const filtered = cutoff === null
351-
? this.snapshots
352-
: this.snapshots.filter((s) => new Date(s.timestamp).getTime() >= cutoff);
350+
const filtered = (cutoff === null
351+
? [...this.snapshots]
352+
: this.snapshots.filter((s) => new Date(s.timestamp).getTime() >= cutoff))
353+
.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
353354

354355
if (filtered.length < 2) return [];
355356

@@ -372,7 +373,7 @@ export class UsageStatsStore {
372373
});
373374
}
374375

375-
if (granularity === "raw") return deltas;
376+
if (granularity === "raw") return bucketize(deltas, 1);
376377

377378
// Bucket into time intervals
378379
const bucketMs =

tests/unit/auth/usage-stats.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -458,6 +458,35 @@ describe("UsageStatsStore", () => {
458458
expect(raw[0].request_count).toBe(0);
459459
});
460460

461+
it("sorts raw history and emits at most one point per timestamp", () => {
462+
const now = Date.now();
463+
const first = new Date(now - 3600_000).toISOString();
464+
const second = new Date(now - 1800_000).toISOString();
465+
const snapshots: UsageSnapshot[] = [
466+
{
467+
timestamp: second,
468+
totals: { input_tokens: 300, output_tokens: 30, request_count: 3, active_accounts: 1 },
469+
},
470+
{
471+
timestamp: first,
472+
totals: { input_tokens: 100, output_tokens: 10, request_count: 1, active_accounts: 1 },
473+
},
474+
{
475+
timestamp: second,
476+
totals: { input_tokens: 200, output_tokens: 20, request_count: 2, active_accounts: 1 },
477+
},
478+
];
479+
480+
persistence = createMockPersistence(snapshots);
481+
store = new UsageStatsStore(persistence);
482+
483+
const raw = store.getHistory("all", "raw");
484+
expect(raw).toHaveLength(1);
485+
expect(raw[0].timestamp).toBe(second);
486+
expect(raw[0].input_tokens).toBe(200);
487+
expect(raw[0].request_count).toBe(2);
488+
});
489+
461490
it("aggregates into hourly buckets", () => {
462491
const now = Date.now();
463492
const hourStart = Math.floor(now / 3600_000) * 3600_000;

web/src/components/UsageChart.tsx

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -24,21 +24,34 @@ const PADDING = { top: 20, right: 20, bottom: 40, left: 65 };
2424

2525
export function buildSmoothPath(points: Point[]): string {
2626
if (points.length === 0) return "";
27-
if (points.length === 1) return `M ${points[0].x},${points[0].y}`;
28-
29-
const commands = [`M ${points[0].x},${points[0].y}`];
30-
for (let i = 0; i < points.length - 1; i++) {
31-
const previous = points[i - 1] ?? points[i];
32-
const current = points[i];
33-
const next = points[i + 1];
34-
const following = points[i + 2] ?? next;
27+
28+
// A path must not revisit an x coordinate. This is defensive for callers
29+
// that receive duplicate buckets; the history API also de-duplicates them.
30+
const uniquePoints = [...points]
31+
.sort((a, b) => a.x - b.x)
32+
.filter((point, index, sorted) => index === 0 || point.x !== sorted[index - 1].x);
33+
if (uniquePoints.length === 1) return `M ${uniquePoints[0].x},${uniquePoints[0].y}`;
34+
35+
const minY = Math.min(...uniquePoints.map((point) => point.y));
36+
const maxY = Math.max(...uniquePoints.map((point) => point.y));
37+
const clampY = (y: number) => Math.min(maxY, Math.max(minY, y));
38+
39+
const commands = [`M ${uniquePoints[0].x},${uniquePoints[0].y}`];
40+
for (let i = 0; i < uniquePoints.length - 1; i++) {
41+
const previous = uniquePoints[i - 1] ?? uniquePoints[i];
42+
const current = uniquePoints[i];
43+
const next = uniquePoints[i + 1];
44+
const following = uniquePoints[i + 2] ?? next;
45+
const segmentWidth = next.x - current.x;
3546
const control1 = {
36-
x: current.x + (next.x - previous.x) / 6,
37-
y: current.y + (next.y - previous.y) / 6,
47+
// Keep both controls inside this segment so x remains monotonic even
48+
// when valid hit-rate points are separated by empty buckets.
49+
x: current.x + segmentWidth / 3,
50+
y: clampY(current.y + (next.y - previous.y) / 6),
3851
};
3952
const control2 = {
40-
x: next.x - (following.x - current.x) / 6,
41-
y: next.y - (following.y - current.y) / 6,
53+
x: next.x - segmentWidth / 3,
54+
y: clampY(next.y - (following.y - current.y) / 6),
4255
};
4356
commands.push(`C ${control1.x},${control1.y} ${control2.x},${control2.y} ${next.x},${next.y}`);
4457
}

web/src/pages/__tests__/usage-stats.test.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,21 @@ describe("UsageStats", () => {
156156
expect(buildSmoothPath([])).toBe("");
157157
});
158158

159+
it("keeps smooth controls within each x segment and y data domain", () => {
160+
const path = buildSmoothPath([
161+
{ x: 0, y: 100 },
162+
{ x: 1, y: 0 },
163+
{ x: 100, y: 1 },
164+
]);
165+
const coordinates = [...path.matchAll(/(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?)/g)];
166+
const xs = coordinates.map((match) => Number(match[1]));
167+
const ys = coordinates.map((match) => Number(match[2]));
168+
169+
expect(xs).toEqual([...xs].sort((a, b) => a - b));
170+
expect(Math.min(...ys)).toBeGreaterThanOrEqual(0);
171+
expect(Math.max(...ys)).toBeLessThanOrEqual(100);
172+
});
173+
159174
it("connects hit-rate points across empty buckets", () => {
160175
const dataWithEmptyBucket: UsageDataPoint[] = [
161176
windowPoints[0],

0 commit comments

Comments
 (0)