Compare commits

...

2 Commits

Author SHA1 Message Date
7d58c78cb9 Fix modal popup
All checks were successful
Build and Push / build (push) Successful in 1m22s
2026-05-01 16:56:33 +02:00
f308c84325 Group F - Fix spending per tenant
All checks were successful
Build and Push / build (push) Successful in 1m22s
2026-05-01 13:34:56 +02:00
7 changed files with 330 additions and 172 deletions

View File

@@ -67,18 +67,12 @@ export default async function TenantDetailPage({
); );
const channelUsers = tenant.spec.channelUsers || {}; const channelUsers = tenant.spec.channelUsers || {};
// Admins inspecting another tenant's usage: pass teamId AND keyAlias so // Bug 19 fix: every viewer (customer or admin) passes the tenant
// the backend filters spend logs by this specific tenant's virtual key. // name to UsageDisplay. The /api/usage route resolves team+alias
// Without keyAlias the response would include sibling tenants in the // from the tenant CR's status and applies the visibility check, so
// same org, since teams are now shared (Slice 2). // no per-role branching is needed here. Previous version only
// Customers viewing their own: pass nothing — backend resolves both // passed identifiers for platform admins; customers got "the first
// from the session-bound tenant. // visible tenant" by API fallback, mingling siblings.
const usageTeamId = user.isPlatform
? tenant.status?.litellmTeamId || undefined
: undefined;
const usageKeyAlias = user.isPlatform
? tenant.status?.litellmKeyAlias || undefined
: undefined;
return ( return (
<div> <div>
@@ -150,7 +144,7 @@ export default async function TenantDetailPage({
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3"> <h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
{t("usage")} {t("usage")}
</h2> </h2>
<UsageDisplay teamId={usageTeamId} keyAlias={usageKeyAlias} /> <UsageDisplay tenant={name} />
</section> </section>
{/* Packages */} {/* Packages */}

View File

@@ -8,64 +8,109 @@ import { safeError } from "@/lib/errors";
/** /**
* GET /api/usage * GET /api/usage
* *
* Customers: tenant resolved server-side from the user's orgId. The * Per-tenant spend/token usage for a given month.
* response is filtered by the tenant's `litellmKeyAlias` so
* sibling tenants in the same org don't bleed into the total.
* Platform admins: may pass ?teamId=... to inspect any team. They may
* also pass ?keyAlias=... to scope to a single tenant.
* *
* Slice 2 note * Resolution rules (in priority order)
* ------------ * ------------------------------------
* LiteLLM teams are now shared across all tenants of an org. The team's * 1. `?tenant=<name>` query param — the canonical path. The route
* `/team/info` budget is the *company* budget; the per-tenant numbers * looks up the PiecedTenant CR by name, runs it through the
* come from filtering spend logs by `key_alias`. If a tenant has no * viewer's visibility filter, and reads `status.litellmTeamId` +
* `litellmKeyAlias` in status (transitional state right after upgrade, * `status.litellmKeyAlias`. This is what the tenant-detail page
* before the operator has reconciled), we fall back to team-level * calls with for both customers and admins.
* filtering — the numbers will be slightly inflated for that one * 2. `?teamId=<id>` (+ optional `?keyAlias=<alias>`) — admin escape
* reconcile cycle. * hatch for debugging across orgs (e.g. opening the platform
* panel without a specific tenant in mind). Platform-only;
* ignored for customer sessions.
* 3. No params — 400. We deliberately do NOT fall back to "the
* first visible tenant". Bug 19: that fallback meant siblings
* in the same org showed identical numbers because the API
* always picked the same "first" tenant regardless of which
* detail page the customer was viewing. Forcing callers to be
* explicit makes the bug structurally impossible to reintroduce.
*
* Filtering
* ---------
* LiteLLM's `/spend/logs/v2` accepts a server-side `key_alias` filter.
* We pass it through directly — no more "fetch all team pages and
* post-filter in JS" (which was O(team_total) memory per request and
* masked the routing bug above by being slow enough that nobody
* noticed which alias was actually being used).
*
* The team-level budget is still surfaced as the *org* budget, since
* teams are org-scoped post-Slice-2. That's intentional: the customer
* sees "your company has X budget remaining" alongside "this tenant
* cost Y this month".
*/ */
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
const user = await getSessionUser(); const user = await getSessionUser();
if (!user) if (!user)
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const tenantName = req.nextUrl.searchParams.get("tenant");
let teamId: string | null = null; let teamId: string | null = null;
let keyAlias: string | null = null; let keyAlias: string | null = null;
if (user.isPlatform) { if (tenantName) {
teamId = req.nextUrl.searchParams.get("teamId") ?? null; // Path 1: resolve from tenant name with visibility check.
keyAlias = req.nextUrl.searchParams.get("keyAlias") ?? null; //
} // listVisibleTenants enforces the same visibility rules as every
// other read endpoint:
// For customers (or admins without explicit params): resolve from // - platform admins see everything
// the user's *visible* tenants. With Slice 6, a `user`-role member // - owners see all tenants in their org
// can only see usage for tenants they're assigned to — a non-assigned // - users see only the tenants they're assigned to (Slice 6)
// user defaults to "no active tenant" (404). //
// // Filtering through that list rather than reading the CR directly
// Owner and platform get the full org-scoped list and pick the first // means a malicious caller can't probe arbitrary tenant names to
// tenant, matching the dashboard's "current instance" semantics. // learn what exists in other orgs.
if (!teamId) {
const allTenants = await listTenants(); const allTenants = await listTenants();
const visible = await listVisibleTenants(user, allTenants); const visible = await listVisibleTenants(user, allTenants);
const orgTenant = visible.find((t) => !!t.status?.litellmTeamId); const tenant = visible.find((t) => t.metadata.name === tenantName);
if (!orgTenant?.status?.litellmTeamId) { if (!tenant) {
return NextResponse.json( return NextResponse.json(
{ error: "No active tenant found for your organization" }, { error: "Tenant not found or not accessible" },
{ status: 404 } { status: 404 }
); );
} }
teamId = orgTenant.status.litellmTeamId; if (!tenant.status?.litellmTeamId) {
// Tenant exists but the operator hasn't reconciled it yet.
// If the operator has populated the per-tenant key alias, filter by it. // Common right after onboarding; the customer should see a
// Falling back to team-level (no alias) will return the org total, which // friendly empty state, not a 500.
// is acceptable transitionally but means siblings' usage shows up here. return NextResponse.json(
if (orgTenant.status.litellmKeyAlias) { { error: "Tenant is still provisioning, no usage data yet" },
keyAlias = orgTenant.status.litellmKeyAlias; { status: 409 }
);
} }
teamId = tenant.status.litellmTeamId;
// litellmKeyAlias is set by the operator's LiteLLM reconcile step
// alongside litellmTeamId, so if teamId is present this should be
// too. Defensive fallback to team-level if missing — in that case
// the customer briefly sees company totals until the next operator
// reconcile, which is better than 500.
keyAlias = tenant.status.litellmKeyAlias ?? null;
} else if (user.isPlatform) {
// Path 2: admin escape hatch.
teamId = req.nextUrl.searchParams.get("teamId");
keyAlias = req.nextUrl.searchParams.get("keyAlias");
if (!teamId) {
return NextResponse.json(
{
error:
"Either ?tenant=<name> or ?teamId=<id> (admin) must be provided",
},
{ status: 400 }
);
}
} else {
// Path 3: no resolution possible. See doc above for why we don't
// pick a default.
return NextResponse.json(
{ error: "Tenant must be specified via ?tenant=<name>" },
{ status: 400 }
);
} }
// Month param: YYYY-MM, defaults to current month // Month param: YYYY-MM, defaults to current month.
const now = new Date(); const now = new Date();
const monthParam = const monthParam =
req.nextUrl.searchParams.get("month") || req.nextUrl.searchParams.get("month") ||
@@ -81,11 +126,11 @@ export async function GET(req: NextRequest) {
try { try {
const teamInfo = await getTeamInfo(teamId); const teamInfo = await getTeamInfo(teamId);
// Fetch all pages from the team. We always query at the team level — // Page through results — server-side filtered by key_alias when
// LiteLLM's /spend/logs/v2 doesn't filter by key_alias reliably across // provided. Pagination still needed because LiteLLM caps
// versions, so we paginate and post-filter in code. For pilot scale // page_size at 100, and a busy tenant can easily exceed that in
// this is cheap; if a single team ever exceeds ~10k entries/month we // a month. With server-side filtering this stays cheap regardless
// can revisit. // of how busy sibling tenants in the same team are.
const allRequests: any[] = []; const allRequests: any[] = [];
let page = 1; let page = 1;
while (true) { while (true) {
@@ -94,33 +139,25 @@ export async function GET(req: NextRequest) {
startStr, startStr,
endStr, endStr,
page, page,
100 100,
keyAlias
); );
allRequests.push(...(result.data || [])); allRequests.push(...(result.data || []));
if (page >= (result.total_pages || 1)) break; if (page >= (result.total_pages || 1)) break;
page++; page++;
// Defensive cap. A pathological response with bogus total_pages
// shouldn't be able to spin us forever. 50 pages × 100 = 5000
// entries/month/tenant is well above any realistic usage at
// pilot scale.
if (page > 50) break;
} }
// Apply key_alias post-filter when scoping to a single tenant. Match // Aggregate by day.
// both `key_alias` (newer LiteLLM) and `metadata.user_api_key_alias`
// (older builds nest it inside metadata).
const scoped = keyAlias
? allRequests.filter((r) => {
const alias =
r.key_alias ??
r.metadata?.user_api_key_alias ??
r.api_key_alias ??
null;
return alias === keyAlias;
})
: allRequests;
// Aggregate by day
const byDay: Record< const byDay: Record<
string, string,
{ inputTokens: number; outputTokens: number; spend: number } { inputTokens: number; outputTokens: number; spend: number }
> = {}; > = {};
for (const r of scoped) { for (const r of allRequests) {
const day = (r.startTime || r.endTime || "").slice(0, 10); const day = (r.startTime || r.endTime || "").slice(0, 10);
if (!day) continue; if (!day) continue;
if (!byDay[day]) if (!byDay[day])
@@ -134,30 +171,30 @@ export async function GET(req: NextRequest) {
.sort(([a], [b]) => a.localeCompare(b)) .sort(([a], [b]) => a.localeCompare(b))
.map(([date, d]) => ({ date, ...d })); .map(([date, d]) => ({ date, ...d }));
const totalInput = scoped.reduce( const totalInput = allRequests.reduce(
(s, r) => s + (r.prompt_tokens || 0), (s, r) => s + (r.prompt_tokens || 0),
0 0
); );
const totalOutput = scoped.reduce( const totalOutput = allRequests.reduce(
(s, r) => s + (r.completion_tokens || 0), (s, r) => s + (r.completion_tokens || 0),
0 0
); );
const totalSpend = scoped.reduce((s, r) => s + (r.spend || 0), 0); const totalSpend = allRequests.reduce((s, r) => s + (r.spend || 0), 0);
return NextResponse.json({ return NextResponse.json({
teamId, teamId,
keyAlias, // null when not filtering — useful for the client to know it sees company-wide data keyAlias, // null when admin queries team-wide (no specific tenant)
month: monthParam, month: monthParam,
currentPeriod: { currentPeriod: {
inputTokens: totalInput, inputTokens: totalInput,
outputTokens: totalOutput, outputTokens: totalOutput,
totalSpend, totalSpend,
requestCount: scoped.length, requestCount: allRequests.length,
}, },
// Budget is always team-level (= company budget). Spend reported // Budget is always team-level (= company budget). Spend reported
// here is the team total, not the per-key total — the customer // here is the team total, not the per-key total — the customer
// wants to see "how much of our company budget is left", not just // wants to see "how much of our company budget is left", not
// "how much has this one tenant cost". // just "how much has this one tenant cost".
budget: { budget: {
maxBudget: teamInfo?.team_info?.max_budget ?? null, maxBudget: teamInfo?.team_info?.max_budget ?? null,
spend: teamInfo?.team_info?.spend ?? 0, spend: teamInfo?.team_info?.spend ?? 0,

View File

@@ -94,17 +94,27 @@ function UsageChart({ data }: { data: DailyUsage[] }) {
/** /**
* Usage display widget. * Usage display widget.
* *
* - Customers: don't pass teamId or keyAlias — the backend resolves both * Pass `tenant=<name>` for the canonical path — works for both
* from the session-bound tenant. * customers and admins, the API resolves team+alias from the tenant
* - Admins inspecting a specific tenant: pass `teamId` (the org-level * CR's status. The visibility check on the API ensures users can't
* LiteLLM team id) AND `keyAlias` (the tenant's virtual-key alias). * query tenants they shouldn't see.
* Without `keyAlias`, the response includes spend from sibling tenants *
* in the same org, since teams are shared since Slice 2. * `teamId`/`keyAlias` remain available as a platform-admin escape
* hatch for cross-org debugging, but the tenant-detail and dashboard
* paths should always use `tenant`.
*
* Bug 19 fix: previous version omitted both props for customer
* sessions, expecting the API to "figure it out". The API's fallback
* was "first visible tenant", which meant siblings in the same org
* showed identical numbers regardless of which detail page was open.
* Now the page passes the tenant name explicitly; no fallback exists.
*/ */
export function UsageDisplay({ export function UsageDisplay({
tenant,
teamId, teamId,
keyAlias, keyAlias,
}: { }: {
tenant?: string | null;
teamId?: string | null; teamId?: string | null;
keyAlias?: string | null; keyAlias?: string | null;
}) { }) {
@@ -121,11 +131,13 @@ export function UsageDisplay({
setError(null); setError(null);
const params = new URLSearchParams({ month }); const params = new URLSearchParams({ month });
if (teamId) { if (tenant) {
params.set("tenant", tenant);
} else if (teamId) {
// Admin escape hatch — only honoured by the API when the
// viewer is platform-role.
params.set("teamId", teamId); params.set("teamId", teamId);
} if (keyAlias) params.set("keyAlias", keyAlias);
if (keyAlias) {
params.set("keyAlias", keyAlias);
} }
fetch(`/api/usage?${params}`) fetch(`/api/usage?${params}`)
@@ -133,7 +145,7 @@ export function UsageDisplay({
.then(setData) .then(setData)
.catch((e) => setError(e.message)) .catch((e) => setError(e.message))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [teamId, keyAlias, month]); }, [tenant, teamId, keyAlias, month]);
useEffect(() => { fetchUsage(); }, [fetchUsage]); useEffect(() => { fetchUsage(); }, [fetchUsage]);

View File

@@ -5,6 +5,7 @@ import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useTranslations, useFormatter } from "next-intl"; import { useTranslations, useFormatter } from "next-intl";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { Modal } from "@/components/ui/modal";
import { StatusBadge } from "@/components/ui/status-badge"; import { StatusBadge } from "@/components/ui/status-badge";
import { formatDateTime, formatRelative } from "@/lib/format"; import { formatDateTime, formatRelative } from "@/lib/format";
@@ -250,43 +251,38 @@ export function ProvisioningStatus({ requestId, canAct }: Props) {
</div> </div>
{confirmCancel && ( {confirmCancel && (
<div <Modal
role="dialog" open={confirmCancel}
aria-modal="true" onClose={() => setConfirmCancel(false)}
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm" ariaLabel={t("cancelConfirmRequestTitle")}
onClick={(e) => {
if (e.target === e.currentTarget) setConfirmCancel(false);
}}
> >
<div className="bg-surface-1 border border-border rounded-xl p-6 max-w-md w-full"> <h3 className="font-display text-lg font-semibold text-text-primary mb-2">
<h3 className="font-display text-lg font-semibold text-text-primary mb-2"> {t("cancelConfirmRequestTitle")}
{t("cancelConfirmRequestTitle")} </h3>
</h3> <p className="text-sm text-text-secondary mb-5">
<p className="text-sm text-text-secondary mb-5"> {t("cancelConfirmRequestDescription")}
{t("cancelConfirmRequestDescription")} </p>
</p> <div className="flex justify-end gap-2">
<div className="flex justify-end gap-2"> <button
<button type="button"
type="button" onClick={() => setConfirmCancel(false)}
onClick={() => setConfirmCancel(false)} disabled={actionPending}
disabled={actionPending} className="text-sm px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary transition-colors"
className="text-sm px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary transition-colors" >
> {tCommon("cancel")}
{tCommon("cancel")} </button>
</button> <button
<button type="button"
type="button" onClick={handleCancel}
onClick={handleCancel} disabled={actionPending}
disabled={actionPending} className="text-sm px-4 py-2 rounded-lg bg-red-500 text-white hover:bg-red-600 transition-colors disabled:opacity-50"
className="text-sm px-4 py-2 rounded-lg bg-red-500 text-white hover:bg-red-600 transition-colors disabled:opacity-50" >
> {actionPending
{actionPending ? tCommon("loading")
? tCommon("loading") : t("cancelRequestConfirm")}
: t("cancelRequestConfirm")} </button>
</button>
</div>
</div> </div>
</div> </Modal>
)} )}
</Card> </Card>
); );

View File

@@ -3,6 +3,7 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Modal } from "@/components/ui/modal";
interface Props { interface Props {
tenantName: string; tenantName: string;
@@ -102,55 +103,50 @@ export function SubscriptionToggle({ tenantName, suspended }: Props) {
)} )}
{confirmOpen && ( {confirmOpen && (
<div <Modal
role="dialog" open={confirmOpen}
aria-modal="true" onClose={() => setConfirmOpen(false)}
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm" ariaLabel={t("cancelConfirmTitle")}
onClick={(e) => {
if (e.target === e.currentTarget) setConfirmOpen(false);
}}
> >
<div className="bg-surface-1 border border-border rounded-xl p-6 max-w-md w-full"> <h3 className="font-display text-lg font-semibold text-text-primary mb-2">
<h3 className="font-display text-lg font-semibold text-text-primary mb-2"> {t("cancelConfirmTitle")}
{t("cancelConfirmTitle")} </h3>
</h3> <p className="text-sm text-text-secondary mb-3">
<p className="text-sm text-text-secondary mb-3"> {t("cancelConfirmDescription")}
{t("cancelConfirmDescription")} </p>
</p> <ul className="text-xs text-text-muted list-disc list-inside space-y-1 mb-5">
<ul className="text-xs text-text-muted list-disc list-inside space-y-1 mb-5"> <li>{t("cancelConfirmBullet1")}</li>
<li>{t("cancelConfirmBullet1")}</li> <li>{t("cancelConfirmBullet2")}</li>
<li>{t("cancelConfirmBullet2")}</li> <li>{t("cancelConfirmBullet3")}</li>
<li>{t("cancelConfirmBullet3")}</li> </ul>
</ul>
{error && ( {error && (
<div className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2 mb-3"> <div className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2 mb-3">
{error} {error}
</div>
)}
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setConfirmOpen(false)}
disabled={submitting}
className="text-sm px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary transition-colors"
>
{tCommon("cancel")}
</button>
<button
type="button"
onClick={() => toggleSuspend(true)}
disabled={submitting}
className="text-sm px-4 py-2 rounded-lg bg-amber-500 text-white hover:bg-amber-600 transition-colors disabled:opacity-50"
>
{submitting
? tCommon("loading")
: t("cancelSubscriptionConfirm")}
</button>
</div> </div>
)}
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setConfirmOpen(false)}
disabled={submitting}
className="text-sm px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary transition-colors"
>
{tCommon("cancel")}
</button>
<button
type="button"
onClick={() => toggleSuspend(true)}
disabled={submitting}
className="text-sm px-4 py-2 rounded-lg bg-amber-500 text-white hover:bg-amber-600 transition-colors disabled:opacity-50"
>
{submitting
? tCommon("loading")
: t("cancelSubscriptionConfirm")}
</button>
</div> </div>
</div> </Modal>
)} )}
</div> </div>
); );

View File

@@ -0,0 +1,89 @@
"use client";
import { useEffect, useRef } from "react";
import { createPortal } from "react-dom";
interface Props {
open: boolean;
/** Called when user clicks the backdrop or presses Escape. */
onClose: () => void;
children: React.ReactNode;
/**
* ARIA label fallback when no labelled element exists inside.
* Optional; if you have a heading inside the modal with id, set
* `aria-labelledby` on a wrapper instead.
*/
ariaLabel?: string;
}
/**
* Portal-based modal.
*
* Why a portal
* ------------
* `position: fixed` becomes positioned relative to a transformed
* ancestor's containing block, not the viewport, when ANY ancestor
* has a `transform`, `perspective`, or `filter` applied. Our
* `animate-in` utility sets `transform: translateY(0)` on a lot of
* dashboard/tenant-detail containers (because of the fade-up
* animation, which uses `animation-fill-mode: both` to keep the
* transform on after the animation finishes). That broke modals
* rendered as in-place children — they centred to the panel they
* lived in, not to the page.
*
* Rendering at `document.body` via `createPortal` escapes every
* containing-block ancestor and gives us true viewport coordinates.
*
* UX details
* ----------
* - Backdrop click triggers `onClose`. (Bubbling check: only fires
* when the click target IS the backdrop, not the panel inside.)
* - Escape key triggers `onClose`. Standard modal expectation.
* - `body` overflow is locked while open so background content
* doesn't scroll behind the modal.
* - Renders nothing on first paint server-side, then mounts on
* client. `useEffect` gating ensures `document.body` is available;
* without it Next.js SSR would throw on `document` reference.
*/
export function Modal({ open, onClose, children, ariaLabel }: Props) {
const closeRef = useRef(onClose);
closeRef.current = onClose;
useEffect(() => {
if (!open) return;
// Lock background scroll. Restore on unmount/close.
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") closeRef.current();
};
window.addEventListener("keydown", onKey);
return () => {
document.body.style.overflow = previousOverflow;
window.removeEventListener("keydown", onKey);
};
}, [open]);
if (!open) return null;
if (typeof document === "undefined") return null;
return createPortal(
<div
role="dialog"
aria-modal="true"
aria-label={ariaLabel}
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div className="bg-surface-1 border border-border rounded-xl p-6 max-w-md w-full max-h-[90vh] overflow-y-auto">
{children}
</div>
</div>,
document.body
);
}

View File

@@ -32,12 +32,43 @@ export async function getTeamSpendLogs(
return litellmFetch(`/global/spend/logs?${params}`); return litellmFetch(`/global/spend/logs?${params}`);
} }
/**
* Fetch one page of spend logs for a team, optionally narrowed to a
* single virtual key by alias.
*
* Slice 2 / Bug 19 context
* ------------------------
* Teams in LiteLLM are now org-scoped (one team per org), and each
* tenant in the org has its own virtual key with `key_alias = tenant
* CR name`. Without `keyAlias`, this returns the full team's spend —
* which mingles every tenant in the org. The portal's per-tenant
* usage view passes `keyAlias` to filter server-side via LiteLLM's
* native `key_alias` query param. Confirmed available on the
* `/spend/logs/v2` endpoint via OpenAPI introspection — no need to
* page-and-post-filter as the previous slice did.
*
* Why this matters
* ----------------
* Previous implementation fetched all team pages, then post-filtered
* by alias in JS. Two problems: (1) at any reasonable scale this is
* O(team_total) memory per request even when only one tenant's data
* is needed; (2) more importantly, when called from the customer
* dashboard without an explicit alias, the route's "pick the first
* visible tenant" fallback meant both Acme tenants showed identical
* numbers — the alias used was always the first tenant in the
* visible list, regardless of which tenant page was being viewed.
*
* The route layer above is responsible for resolving the tenant
* identity correctly and passing the right alias here. This
* function's only job is to pass it through to LiteLLM.
*/
export async function getTeamSpendLogsV2( export async function getTeamSpendLogsV2(
teamId: string, teamId: string,
startDate: string, startDate: string,
endDate: string, endDate: string,
page: number = 1, page: number = 1,
pageSize: number = 100 pageSize: number = 100,
keyAlias?: string | null
) { ) {
const params = new URLSearchParams({ const params = new URLSearchParams({
team_id: teamId, team_id: teamId,
@@ -46,6 +77,9 @@ export async function getTeamSpendLogsV2(
page: String(page), page: String(page),
page_size: String(pageSize), page_size: String(pageSize),
}); });
if (keyAlias) {
params.set("key_alias", keyAlias);
}
return litellmFetch(`/spend/logs/v2?${params}`); return litellmFetch(`/spend/logs/v2?${params}`);
} }