Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions backend/app/analytics.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from collections import defaultdict
from datetime import UTC, datetime, timedelta
from datetime import date as date_cls
import math
from typing import Any

from . import db
Expand Down Expand Up @@ -683,7 +684,7 @@ def model_breakdown(
"WHERE date BETWEEN ? AND ? ORDER BY date ASC",
(start_iso, end_iso),
).fetchall()

# Aggregate code from raw_json into per-editor rows.
code_by_editor: dict[str, dict[str, Any]] = {}
for dr in day_rows:
Expand Down Expand Up @@ -760,7 +761,7 @@ def model_breakdown(
"GROUP BY editor, model",
(start_iso, end_iso, scope),
).fetchall()

# Combine code (per-editor) and chat (per-model) rows.
rows = list(code_by_editor.values()) + [
{
Expand Down Expand Up @@ -2124,12 +2125,25 @@ def ai_credits_summary(
key=lambda row: row["quantity"],
reverse=True,
)
if high_pct < 45.0:
distance = 45.0 - high_pct
elif high_pct > 55.0:
distance = high_pct - 55.0
else:
distance = 0.0

balance_factor = max(0.0, 1.0 - (distance / 45.0))
# Logarithmic volume dampening prevents massive unbalance from overshadowing perfect splits,
# while squaring the balance factor penalizes imbalance more sharply.
balanced_score = math.log10(total) * (balance_factor ** 2)

balanced_users.append(
{
"login": login,
"total_ai_credits": round(total, 2),
"high_pct": round(high_pct, 2),
"low_pct": round(low_pct, 2),
"balanced_score": round(balanced_score, 2),
"models": [
{
"model": row["model"],
Expand All @@ -2141,7 +2155,7 @@ def ai_credits_summary(
],
}
)
balanced_users.sort(key=lambda row: row["total_ai_credits"], reverse=True)
balanced_users.sort(key=lambda row: row["balanced_score"], reverse=True)

# Headline totals from the ai_credit/usage aggregate endpoint (fresher
# than per-day row sums). Only include when the stored period covers the
Expand Down
82 changes: 82 additions & 0 deletions backend/tests/test_billing_usage.py
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,87 @@ def test_ai_credits_summary_returns_balanced_users() -> None:
assert all(user["login"] != "high_only" for user in out["balanced_users"])


def test_balanced_users_ranking_prioritizes_perfect_balance_and_volume() -> None:
"""Balanced users are sorted by combined score of closeness to the 45-55% plateau and volume."""
# Arrange
db.init_db()
db.replace_billing_usage(
[
# User 1: total = 10, perfectly 50/50
# Within plateau => distance = 0 => balance_factor = 1.0 => score = log10(10) * 1.0 = 1.00
{
"date": "2026-06-01",
"login": "user_low_vol_perfect",
"product": "Copilot",
"sku": "copilot_ai_credit",
"quantity": 5,
"net_amount_usd": 0.20,
"model": "Claude Opus 4.6",
},
{
"date": "2026-06-01",
"login": "user_low_vol_perfect",
"product": "Copilot",
"sku": "copilot_ai_credit",
"quantity": 5,
"net_amount_usd": 0.20,
"model": "Claude Sonnet 4.6",
},
# User 2: total = 10000, 55/45 balance (perfect mix sample)
# Within plateau => distance = 0 => balance_factor = 1.0 => score = log10(10000) * 1.0 = 4.00
{
"date": "2026-06-01",
"login": "user_high_vol_balanced_plateau",
"product": "Copilot",
"sku": "copilot_ai_credit",
"quantity": 5500,
"net_amount_usd": 220.00,
"model": "Claude Opus 4.6",
},
{
"date": "2026-06-01",
"login": "user_high_vol_balanced_plateau",
"product": "Copilot",
"sku": "copilot_ai_credit",
"quantity": 4500,
"net_amount_usd": 180.00,
"model": "Claude Sonnet 4.6",
},
# User 3: total = 10000, 40/60 balance
# Outside plateau => high_pct = 40.0 => distance = 5.0 => balance_factor = 1 - 5/45 = 8/9 => score = 4.00 * (8/9)^2 = 3.16
{
"date": "2026-06-01",
"login": "user_high_vol_less_balanced",
"product": "Copilot",
"sku": "copilot_ai_credit",
"quantity": 4000,
"net_amount_usd": 160.00,
"model": "Claude Opus 4.6",
},
{
"date": "2026-06-01",
"login": "user_high_vol_less_balanced",
"product": "Copilot",
"sku": "copilot_ai_credit",
"quantity": 6000,
"net_amount_usd": 240.00,
"model": "Claude Sonnet 4.6",
},
]
)

# Act
out = analytics.ai_credits_summary(start="2026-06-01", end="2026-06-01")
ranked = [u["login"] for u in out["balanced_users"]]

# Assert
# Expected scores:
# user_high_vol_balanced_plateau: log10(10000) * 1.0 = 4.00
# user_high_vol_less_balanced: log10(10000) * (8/9)^2 = 3.16
# user_low_vol_perfect: log10(10) * 1.0 = 1.00
assert ranked == ["user_high_vol_balanced_plateau", "user_high_vol_less_balanced", "user_low_vol_perfect"]


def test_model_tier_auto_gpt54_counts_as_high() -> None:
"""High-tier model matches should override the generic Auto:* low-tier bucket."""
# Act + Assert
Expand Down Expand Up @@ -614,6 +695,7 @@ def test_ai_credits_summary_includes_headline_from_meta(billing_db: None) -> Non
db.set_meta("ai_credit_headline_qty", "806917.98")
db.set_meta("ai_credit_headline_net_usd", "1234.98")
db.set_meta("ai_credit_headline_gross_usd", "8069.18")
db.set_meta("ai_credit_headline_period", "2026-06")
db.set_meta("ai_credit_headline_at", "2026-06-26T12:00:00+00:00")

out = analytics.ai_credits_summary(start="2026-06-01", end="2026-06-02")
Expand Down
2 changes: 1 addition & 1 deletion backend/tests/test_features.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ def _seed_feature_data() -> None:
def test_features_endpoint_returns_aggregated_features() -> None:
"""GET /api/features returns summed feature metrics across the window."""
_seed_feature_data()
resp = client.get("/api/features?days=30")
resp = client.get("/api/features?start=2026-06-14&end=2026-06-15")
assert resp.status_code == 200
data = resp.json()
assert "features" in data
Expand Down
39 changes: 0 additions & 39 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

29 changes: 19 additions & 10 deletions frontend/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,7 @@ export interface AiCreditBalancedUser {
total_ai_credits: number;
high_pct: number;
low_pct: number;
balanced_score: number;
models: AiCreditBalancedModelRow[];
}

Expand Down Expand Up @@ -545,11 +546,19 @@ export interface AiCreditsProjection {
}

async function getJson<T>(path: string): Promise<T> {
const r = await fetch(path);
if (!r.ok) throw new Error(`${path}: ${r.status} ${r.statusText}`);
// Prepend base path so API requests resolve under the same base prefix (/copilot/api/...)
const prefix = import.meta.env.BASE_URL.replace(/\/$/, "");
const fullPath = path.startsWith("/api") ? `${prefix}${path}` : path;
const r = await fetch(fullPath);
Comment on lines 548 to +552
if (!r.ok) throw new Error(`${fullPath}: ${r.status} ${r.statusText}`);
return (await r.json()) as T;
}

function apiPath(path: string): string {
const prefix = import.meta.env.BASE_URL.replace(/\/$/, "");
return path.startsWith("/api") ? `${prefix}${path}` : path;
}

export const api = {
kpis: (p: WindowParams = {}) => getJson<Kpis>(`/api/kpis${qs(p)}`),
trends: (p: WindowParams = { days: 90 }) => getJson<TrendPoint[]>(`/api/trends${qs(p)}`),
Expand Down Expand Up @@ -583,7 +592,7 @@ export const api = {
getJson<AiCreditsProjection>("/api/ai-credits/projection"),
projections: () => getJson<Projections>("/api/projections"),
runSnapshot: async (): Promise<unknown> => {
const r = await fetch("/api/snapshot/run", { method: "POST" });
const r = await fetch(apiPath("/api/snapshot/run"), { method: "POST" });
if (!r.ok) {
let detail = `${r.status} ${r.statusText}`;
try {
Expand All @@ -601,7 +610,7 @@ export const api = {
body.set("file", file);
const headers: Record<string, string> = {};
if (token) headers["X-Admin-Token"] = token;
const r = await fetch("/api/data/import-file", { method: "POST", body, headers });
const r = await fetch(apiPath("/api/data/import-file"), { method: "POST", body, headers });
if (!r.ok) {
let detail = `${r.status} ${r.statusText}`;
try {
Expand All @@ -617,7 +626,7 @@ export const api = {
exportData: async (token?: string): Promise<void> => {
const headers: Record<string, string> = {};
if (token) headers["X-Admin-Token"] = token;
const r = await fetch("/api/data/export", { headers });
const r = await fetch(apiPath("/api/data/export"), { headers });
if (!r.ok) throw new Error(`export failed: ${r.status} ${r.statusText}`);
const blob = await r.blob();
const disposition = r.headers.get("Content-Disposition") ?? "";
Expand All @@ -637,7 +646,7 @@ export const api = {
body.set("file", file);
const headers: Record<string, string> = {};
if (token) headers["X-Admin-Token"] = token;
const r = await fetch(`/api/data/import-db?mode=${mode}`, { method: "POST", body, headers });
const r = await fetch(apiPath(`/api/data/import-db?mode=${mode}`), { method: "POST", body, headers });
if (!r.ok) {
let detail = `${r.status} ${r.statusText}`;
try {
Expand All @@ -652,7 +661,7 @@ export const api = {
},
listUsageReports: () => getJson<UsageReportListResponse>("/api/usage-reports"),
createUsageReport: async (req: UsageReportCreateRequest): Promise<UsageReportExport> => {
const r = await fetch("/api/usage-reports", {
const r = await fetch(apiPath("/api/usage-reports"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(req),
Expand All @@ -672,7 +681,7 @@ export const api = {
getUsageReport: (reportId: string) =>
getJson<UsageReportExport>(`/api/usage-reports/${encodeURIComponent(reportId)}`),
downloadUsageReport: async (reportId: string): Promise<void> => {
const r = await fetch(`/api/usage-reports/${encodeURIComponent(reportId)}/download`);
const r = await fetch(apiPath(`/api/usage-reports/${encodeURIComponent(reportId)}/download`));
if (!r.ok) {
let detail = `${r.status} ${r.statusText}`;
try {
Expand All @@ -697,7 +706,7 @@ export const api = {
URL.revokeObjectURL(url);
},
importUsageReport: async (reportId: string): Promise<UsageReportImportResponse> => {
const r = await fetch(`/api/usage-reports/${encodeURIComponent(reportId)}/import`, {
const r = await fetch(apiPath(`/api/usage-reports/${encodeURIComponent(reportId)}/import`), {
method: "POST",
});
if (!r.ok) {
Expand All @@ -714,7 +723,7 @@ export const api = {
},
validateAdminToken: async (token: string): Promise<boolean> => {
try {
const r = await fetch(`/api/auth/validate-admin?token=${encodeURIComponent(token)}`);
const r = await fetch(apiPath(`/api/auth/validate-admin?token=${encodeURIComponent(token)}`));
if (!r.ok) return false;
const payload = (await r.json()) as { valid?: boolean };
return payload.valid === true;
Expand Down
Loading