diff --git a/litellm/litellm_core_utils/get_blog_posts.py b/litellm/litellm_core_utils/get_blog_posts.py index 276d2a968a1..0dd09064dcc 100644 --- a/litellm/litellm_core_utils/get_blog_posts.py +++ b/litellm/litellm_core_utils/get_blog_posts.py @@ -22,6 +22,7 @@ from litellm import verbose_logger from litellm.types.llms.base import LiteLLMBaseModel BLOG_POSTS_TTL_SECONDS: Final[int] = 3600 # 1 hour +BLOG_POSTS_MAX: Final[int] = 6 class BlogPost(LiteLLMBaseModel): @@ -40,7 +41,7 @@ class GetBlogPosts: Fetches, validates, and caches LiteLLM blog posts. - Fetches RSS feed from docs site with a 5-second timeout - - Parses the XML and extracts the latest blog post + - Parses the XML and extracts the latest BLOG_POSTS_MAX blog posts - Caches the result in-process for BLOG_POSTS_TTL_SECONDS (1 hour) - Falls back to the bundled local backup on any failure """ @@ -66,7 +67,7 @@ class GetBlogPosts: return response.text @staticmethod - def parse_rss_to_posts(xml_text: str, max_posts: int = 1) -> list[dict[str, str]]: + def parse_rss_to_posts(xml_text: str, max_posts: int = BLOG_POSTS_MAX) -> list[dict[str, str]]: """ Parse RSS XML and return a list of blog post dicts. diff --git a/litellm/proxy/public_endpoints/public_endpoints.py b/litellm/proxy/public_endpoints/public_endpoints.py index 1cccc6b2cfb..47caacdcfff 100644 --- a/litellm/proxy/public_endpoints/public_endpoints.py +++ b/litellm/proxy/public_endpoints/public_endpoints.py @@ -490,7 +490,7 @@ async def get_litellm_blog_posts(): verbose_logger.warning("LiteLLM: get_litellm_blog_posts endpoint fallback triggered: %s", str(e)) posts_data = GetBlogPosts.load_local_blog_posts() - posts: Final = [BlogPost(**p) for p in posts_data[:5]] + posts: Final = [BlogPost(**p) for p in posts_data] return BlogPostsResponse(posts=posts) diff --git a/tests/unit/proxy/public_endpoints/test_blog_posts_endpoint.py b/tests/unit/proxy/public_endpoints/test_blog_posts_endpoint.py index c206a91bd81..dbbc31279c3 100644 --- a/tests/unit/proxy/public_endpoints/test_blog_posts_endpoint.py +++ b/tests/unit/proxy/public_endpoints/test_blog_posts_endpoint.py @@ -45,8 +45,8 @@ def test_get_blog_posts_returns_response_shape(client): assert post["url"] == "https://www.litellm.ai/blog/test" -def test_get_blog_posts_limits_to_five(client): - """Endpoint returns at most 5 posts.""" +def test_get_blog_posts_returns_every_fetched_post(client): + """The parser owns the cap (BLOG_POSTS_MAX), so the endpoint passes every fetched post through.""" many_posts = [ { "title": f"Post {i}", @@ -64,7 +64,7 @@ def test_get_blog_posts_limits_to_five(client): response = client.get("/public/litellm_blog_posts") assert response.status_code == 200 - assert len(response.json()["posts"]) == 5 + assert [p["title"] for p in response.json()["posts"]] == [f"Post {i}" for i in range(10)] def test_get_blog_posts_returns_local_backup_on_failure(client): diff --git a/tests/unit/test_get_blog_posts.py b/tests/unit/test_get_blog_posts.py index 241dce23633..5686f45efd3 100644 --- a/tests/unit/test_get_blog_posts.py +++ b/tests/unit/test_get_blog_posts.py @@ -2,17 +2,18 @@ import time from unittest.mock import MagicMock, patch +from xml.etree import ElementTree import pytest import litellm from litellm.litellm_core_utils.get_blog_posts import ( + BLOG_POSTS_MAX, BlogPost, BlogPostsResponse, GetBlogPosts, get_blog_posts, ) -from xml.etree import ElementTree SAMPLE_RSS = """\ @@ -71,6 +72,18 @@ def test_parse_rss_to_posts_multiple(): assert posts[1]["title"] == "Second Post" +def test_parse_rss_to_posts_default_cap(): + items = "".join( + f"Post {i}https://docs.litellm.ai/blog/{i}" + f"DThu, 0{1 + i % 9} Jan 2026 00:00:00 GMT" + for i in range(BLOG_POSTS_MAX + 1) + ) + posts = GetBlogPosts.parse_rss_to_posts(f"{items}") + assert len(posts) == BLOG_POSTS_MAX + assert posts[0]["title"] == "Post 0" + assert posts[-1]["title"] == f"Post {BLOG_POSTS_MAX - 1}" + + def test_parse_rss_to_posts_invalid_xml(): with pytest.raises(ElementTree.ParseError): GetBlogPosts.parse_rss_to_posts("not xml") @@ -82,9 +95,7 @@ def test_parse_rss_to_posts_missing_channel(): def test_validate_blog_posts_valid(): - posts = [ - {"title": "T", "description": "D", "date": "2026-01-01", "url": "https://x.com"} - ] + posts = [{"title": "T", "description": "D", "date": "2026-01-01", "url": "https://x.com"}] assert GetBlogPosts.validate_blog_posts(posts) is True @@ -108,7 +119,7 @@ def test_get_blog_posts_success(): ): posts = get_blog_posts(url=litellm.blog_posts_url) - assert len(posts) == 1 + assert len(posts) == 2 assert posts[0]["title"] == "Test Post" @@ -163,9 +174,7 @@ def test_get_blog_posts_ttl_cache_not_refetched(): m.raise_for_status = MagicMock() return m - with patch( - "litellm.litellm_core_utils.get_blog_posts.httpx.get", side_effect=mock_get - ): + with patch("litellm.litellm_core_utils.get_blog_posts.httpx.get", side_effect=mock_get): posts = get_blog_posts(url=litellm.blog_posts_url) assert call_count == 0 # cache hit, no fetch @@ -196,7 +205,7 @@ def test_get_blog_posts_ttl_expired_refetches(): posts = get_blog_posts(url=litellm.blog_posts_url) mock_get.assert_called_once() - assert len(posts) == 1 + assert len(posts) == 2 def test_get_blog_posts_local_env_var_skips_remote(monkeypatch): @@ -219,9 +228,5 @@ def test_blog_post_pydantic_model(): def test_blog_posts_response_pydantic_model(): - resp = BlogPostsResponse( - posts=[ - BlogPost(title="T", description="D", date="2026-01-01", url="https://x.com") - ] - ) + resp = BlogPostsResponse(posts=[BlogPost(title="T", description="D", date="2026-01-01", url="https://x.com")]) assert len(resp.posts) == 1 diff --git a/ui/litellm-dashboard/src/app/(dashboard)/home/_components/HomePage.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/home/_components/HomePage.integration.test.tsx new file mode 100644 index 00000000000..ad3f80b7d78 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/home/_components/HomePage.integration.test.tsx @@ -0,0 +1,149 @@ +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { DailyActivityAggregatedResponse } from "@/components/UsagePage/dailyActivityApi"; +import { EMPTY_DAILY_ACTIVITY_METADATA } from "@/components/UsagePage/dailyActivityApi"; +import { all_admin_roles } from "@/utils/roles"; +import HomePage from "./HomePage"; +import { WHATS_NEW_ITEMS } from "./homeContent"; + +const auth = vi.hoisted(() => ({ + accessToken: "sk-test", + userRole: "", + userId: "u1", + isViewOnly: false, +})); +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: () => auth })); + +const network = vi.hoisted(() => ({ + aggregated: vi.fn(), + gateway: vi.fn(), + userInfo: vi.fn(), +})); +vi.mock("@/components/networking", async (importOriginal) => ({ + ...(await importOriginal()), + dailyActivityAggregatedCall: (...args: unknown[]) => network.aggregated(...args), + gatewayDailyActivityCall: (...args: unknown[]) => network.gateway(...args), + userGetInfoV2: (...args: unknown[]) => network.userInfo(...args), + getProxyBaseUrl: () => "http://proxy.test", +})); + +const blogPosts = { posts: [{ title: "Post one", description: "d", date: "2026-10-09", url: "https://x/1" }] }; + +const dayResult = (date: string, model: string, tokens: number) => { + const metrics = { + spend: 1, + prompt_tokens: tokens, + completion_tokens: 0, + total_tokens: tokens, + api_requests: 1, + successful_requests: 1, + failed_requests: 0, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }; + return { + date, + metrics, + breakdown: { + models: {}, + model_groups: { [model]: { metrics, metadata: {}, api_key_breakdown: {} } }, + providers: {}, + api_keys: {}, + entities: {}, + mcp_servers: {}, + }, + }; +}; + +const activity: DailyActivityAggregatedResponse = { + results: [ + dayResult("2026-10-08", "gpt-6.1-sol", 900), + dayResult("2026-10-09", "claude-haiku-5-5", 100), + ] as unknown as DailyActivityAggregatedResponse["results"], + metadata: { + ...EMPTY_DAILY_ACTIVITY_METADATA, + total_spend: 501.74, + total_api_requests: 5118, + total_successful_requests: 5075, + total_failed_requests: 43, + total_tokens: 98_600_000, + }, +}; + +const renderHome = () => + render( + + + , + ); + +describe("HomePage", () => { + beforeEach(() => { + auth.isViewOnly = false; + auth.userRole = all_admin_roles[0]!; + network.aggregated.mockReset().mockResolvedValue(activity); + network.gateway.mockReset().mockResolvedValue({ total_successful_requests: 0, total_failed_requests: 0 }); + network.userInfo.mockReset().mockResolvedValue({ user_info: { max_budget: null } }); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify(blogPosts), { status: 200 }))); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("shows the usage totals from the daily activity API and links Create New Virtual Key to the key creation flow", async () => { + renderHome(); + expect(screen.getByRole("heading", { level: 1, name: "Home" })).toBeInTheDocument(); + expect(await screen.findByText("$501.74")).toBeInTheDocument(); + expect(screen.getByText("5,118")).toBeInTheDocument(); + expect(screen.getByText("98.6M")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /create new virtual key/i })).toHaveAttribute( + "href", + expect.stringMatching(/\/api-keys\?create=true$/), + ); + expect(network.aggregated).toHaveBeenCalledWith("user", expect.objectContaining({ accessToken: "sk-test" })); + }); + + it("hides Create New Virtual Key from view-only users", () => { + auth.isViewOnly = true; + renderHome(); + expect(screen.queryByRole("link", { name: /create new virtual key/i })).not.toBeInTheDocument(); + }); + + it("shows an error with a retry instead of zero totals when the usage request fails", async () => { + network.aggregated.mockRejectedValueOnce(new Error("boom")); + renderHome(); + expect(await screen.findByText("Failed to load usage")).toBeInTheDocument(); + expect(screen.queryByText("$0.00")).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Retry" })); + expect(await screen.findByText("$501.74")).toBeInTheDocument(); + expect(network.aggregated).toHaveBeenCalledTimes(2); + }); + + it("lists the curated launches newest first with their links", () => { + renderHome(); + const section = screen.getByTestId("home-whats-new"); + const links = within(section).getAllByRole("link"); + expect(links.map((link) => link.getAttribute("href"))).toEqual(WHATS_NEW_ITEMS.map((item) => item.href)); + const dates = WHATS_NEW_ITEMS.map((item) => item.publishedOn); + expect(dates).toEqual([...dates].sort().reverse()); + }); + + it("ranks model groups by token share on the leaderboard", async () => { + renderHome(); + const board = screen.getByTestId("home-leaderboard"); + expect(await within(board).findByText("gpt-6.1-sol")).toBeInTheDocument(); + const rows = within(board).getAllByRole("listitem"); + expect(within(rows[0]!).getByText("gpt-6.1-sol")).toBeInTheDocument(); + expect(within(rows[0]!).getByText("90.0%")).toBeInTheDocument(); + expect(within(rows[1]!).getByText("claude-haiku-5-5")).toBeInTheDocument(); + }); + + it("renders the blog posts fetched from /public/litellm_blog_posts in the Updates feed", async () => { + renderHome(); + const updates = screen.getByTestId("home-updates"); + expect(await within(updates).findByRole("link", { name: /post one/i })).toHaveAttribute("href", "https://x/1"); + expect(fetch).toHaveBeenCalledWith("http://proxy.test/public/litellm_blog_posts"); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/home/_components/HomePage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/home/_components/HomePage.tsx new file mode 100644 index 00000000000..f4519958e9d --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/home/_components/HomePage.tsx @@ -0,0 +1,108 @@ +"use client"; + +import { ArrowRight, Plus, Sparkles } from "lucide-react"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { Page } from "@/components/shared/Page"; +import { PageHeader, PageHeaderControls, PageHeaderTitle } from "@/components/shared/PageHeader"; +import { uiHref } from "@/utils/uiHref"; +import { Leaderboard, useBreakdown } from "@/app/(dashboard)/usage/_components/components/overview/BreakdownChart"; +import { Panel } from "@/app/(dashboard)/usage/_components/components/overview/Primitives"; +import UsageStatStrip from "@/app/(dashboard)/usage/_components/components/overview/UsageStatStrip"; +import { formatPublishedOn, WHATS_NEW_ITEMS } from "./homeContent"; +import UpdatesFeed from "./UpdatesFeed"; +import { HOME_USAGE_DAYS, useHomeUsage } from "./useHomeUsage"; + +const LEADERBOARD = { metric: "tokens", dimension: "model_groups" } as const; + +export default function HomePage() { + const { isViewOnly } = useAuthorized(); + const usage = useHomeUsage(); + const { series, ranking } = useBreakdown(usage.results, LEADERBOARD, 8); + + return ( + + + Home + {!isViewOnly && ( + + }> + + Create New Virtual Key + + + )} + + + + {usage.failed ? ( + + Failed to load usage + + Retry + + + ) : ( + + )} + + + + + + + + + What's new in LiteLLM? + + + + {WHATS_NEW_ITEMS.map((item) => { + const Icon = item.icon; + return ( + + + + + + {item.title} + {item.description} + + {formatPublishedOn(item.publishedOn)} + + + + + ); + })} + + + + + + + + + + + + ); +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/home/_components/UpdatesFeed.tsx b/ui/litellm-dashboard/src/app/(dashboard)/home/_components/UpdatesFeed.tsx new file mode 100644 index 00000000000..ddb135d6f4d --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/home/_components/UpdatesFeed.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { ArrowUpRight, Newspaper } from "lucide-react"; +import { useBlogPosts, type BlogPost } from "@/app/(dashboard)/hooks/blogPosts/useBlogPosts"; +import { useDisableBlogPosts } from "@/app/(dashboard)/hooks/useDisableBlogPosts"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; + +export const BLOG_URL = "https://docs.litellm.ai/blog"; +const MAX_ROWS = 6; +const DAY_MS = 86_400_000; + +const relativeTime = new Intl.RelativeTimeFormat("en", { numeric: "auto" }); + +export const formatRelativeDate = (isoDate: string, now: Date = new Date()): string => { + const published = new Date(`${isoDate}T00:00:00`); + if (Number.isNaN(published.getTime())) return isoDate; + const days = Math.max(0, Math.round((now.getTime() - published.getTime()) / DAY_MS)); + if (days < 7) return relativeTime.format(-days, "day"); + if (days < 30) return relativeTime.format(-Math.round(days / 7), "week"); + if (days < 365) return relativeTime.format(-Math.round(days / 30), "month"); + return relativeTime.format(-Math.round(days / 365), "year"); +}; + +const Row = ({ children }: { children: React.ReactNode }) => ( + + + + + {children} + +); + +/** Latest posts from the LiteLLM blog RSS feed, via /public/litellm_blog_posts, one row per post. */ +export default function UpdatesFeed({ className }: { className?: string }) { + const disabled = useDisableBlogPosts(); + const { data, isLoading, isError, refetch } = useBlogPosts(); + if (disabled) return null; + + const posts = data?.posts.slice(0, MAX_ROWS) ?? []; + + return ( + + + + Updates + + }> + View all + + + + {isLoading && ( + + {[0, 1, 2, 3].map((i) => ( + + + + + + ))} + + )} + {isError && ( + + Failed to load posts + void refetch()}> + Retry + + + )} + {data && posts.length === 0 && No posts yet} + {posts.length > 0 && ( + + {posts.map((post: BlogPost) => ( + + {formatRelativeDate(post.date)} + + {post.title} + + {post.description} + + ))} + + )} + + ); +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/home/_components/homeContent.ts b/ui/litellm-dashboard/src/app/(dashboard)/home/_components/homeContent.ts new file mode 100644 index 00000000000..41d1aac25de --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/home/_components/homeContent.ts @@ -0,0 +1,40 @@ +import { Box, Scale, Zap, type LucideIcon } from "lucide-react"; + +export interface Launch { + readonly icon: LucideIcon; + readonly title: string; + readonly description: string; + readonly href: string; + readonly publishedOn: string; +} + +const LAUNCHES: readonly Launch[] = [ + { + icon: Box, + title: "Claude Haiku 5.5", + description: "Anthropic's newest small model, with day 0 pricing on Anthropic, Bedrock and Vertex AI.", + href: "https://docs.litellm.ai/blog/claude-haiku-5-5", + publishedOn: "2026-10-07", + }, + { + icon: Scale, + title: "Decision Models", + description: "Call decision models at /v1/decisions and try them in the Decisions playground.", + href: "https://docs.litellm.ai/docs/decisions", + publishedOn: "2026-10-07", + }, + { + icon: Zap, + title: "OpenAI Ultrafast", + description: "service_tier: ultrafast on GPT-6 Astra and GPT-6.1 Sol, including /ultrafast in Codex.", + href: "https://docs.litellm.ai/docs/providers/openai/ultrafast", + publishedOn: "2026-10-06", + }, +]; + +export const WHATS_NEW_ITEMS: readonly Launch[] = [...LAUNCHES].sort((a, b) => + b.publishedOn.localeCompare(a.publishedOn), +); + +export const formatPublishedOn = (isoDate: string): string => + new Date(`${isoDate}T00:00:00`).toLocaleDateString("en-US", { month: "short", day: "numeric" }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/home/_components/useHomeUsage.ts b/ui/litellm-dashboard/src/app/(dashboard)/home/_components/useHomeUsage.ts new file mode 100644 index 00000000000..fe6931bb6f5 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/home/_components/useHomeUsage.ts @@ -0,0 +1,86 @@ +import { useEffect, useMemo, useState } from "react"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { useCurrentUser } from "@/app/(dashboard)/hooks/users/useCurrentUser"; +import { gatewayDailyActivityCall } from "@/components/networking"; +import { toDailyData } from "@/components/UsagePage/dailyActivityApi"; +import type { DailyData } from "@/components/UsagePage/types"; +import { all_admin_roles } from "@/utils/roles"; +import { useAggregatedDailyActivity } from "@/app/(dashboard)/usage/_components/hooks/useAggregatedDailyActivity"; +import { ENTITY_API } from "@/app/(dashboard)/usage/_components/components/EntityUsage/entityFetchFns"; +import type { GatewayActivity } from "@/app/(dashboard)/usage/_components/components/gatewayActivity"; +import { EMPTY_DAILY_ACTIVITY_METADATA, type DailyActivityRequest } from "@/components/UsagePage/dailyActivityApi"; +import { + overviewTotals, + type OverviewTotals, +} from "@/app/(dashboard)/usage/_components/components/overview/overviewData"; + +export const HOME_USAGE_DAYS = 7; + +export interface HomeUsage { + results: readonly DailyData[]; + totals: OverviewTotals; + loading: boolean; + requestCountsPending: boolean; + failed: boolean; + budget: number | null; + retry: () => void; +} + +/** Last HOME_USAGE_DAYS of the signed-in user's view of usage: deployment-wide for admins, their own otherwise. */ +export function useHomeUsage(): HomeUsage { + const { accessToken, userRole, userId } = useAuthorized(); + const { data: currentUser } = useCurrentUser(); + const isAdmin = all_admin_roles.includes(userRole || ""); + const [attempt, setAttempt] = useState(0); + const range = useMemo(() => { + const endTime = new Date(); + const startTime = new Date(endTime.getTime() - HOME_USAGE_DAYS * 24 * 60 * 60 * 1000); + return { startTime, endTime }; + }, []); + + const request = useMemo( + () => + accessToken && (isAdmin || userId) + ? { accessToken, ...range, entityIds: isAdmin ? null : [userId as string] } + : null, + [accessToken, isAdmin, range, userId], + ); + const { data, loading, failed } = useAggregatedDailyActivity({ + fetch: () => ENTITY_API.user.aggregated(request as DailyActivityRequest), + enabled: request !== null, + deps: [accessToken, isAdmin, userId, range.startTime.toISOString(), attempt], + }); + + const [gateway, setGateway] = useState(null); + useEffect(() => { + if (!isAdmin || !accessToken) return; + let cancelled = false; + gatewayDailyActivityCall(accessToken, range.startTime, range.endTime) + .then((activity: GatewayActivity) => { + const recorded = activity.total_successful_requests + activity.total_failed_requests > 0; + if (!cancelled) setGateway(recorded ? activity : null); + }) + .catch(() => { + if (!cancelled) setGateway(null); + }); + return () => { + cancelled = true; + }; + }, [isAdmin, accessToken, range, attempt]); + + const results = useMemo(() => toDailyData(data), [data]); + const totals = useMemo( + () => overviewTotals(data.metadata ?? EMPTY_DAILY_ACTIVITY_METADATA, gateway), + [data.metadata, gateway], + ); + + return { + results, + totals, + loading, + requestCountsPending: loading && gateway === null, + failed, + budget: currentUser?.max_budget ?? null, + retry: () => setAttempt((n) => n + 1), + }; +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/home/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/home/page.tsx new file mode 100644 index 00000000000..06e3b000816 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/home/page.tsx @@ -0,0 +1,13 @@ +"use client"; + +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import LoadingScreen from "@/components/common_components/LoadingScreen"; +import HomePage from "./_components/HomePage"; + +export default function HomeRoutePage() { + const { isLoading, isAuthorized } = useAuthorized(); + if (isLoading || !isAuthorized) { + return ; + } + return ; +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/legacyPageRoutes.ts b/ui/litellm-dashboard/src/app/(dashboard)/legacyPageRoutes.ts index 09cc4ef0a75..f4489625dfe 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/legacyPageRoutes.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/legacyPageRoutes.ts @@ -2,6 +2,7 @@ import { uiHref } from "@/utils/uiHref"; const LEGACY_PAGE_ROUTES: ReadonlyMap = new Map( Object.entries({ + home: "home", "api-keys": "api-keys", models: "models-and-endpoints", api_ref: "api-reference", diff --git a/ui/litellm-dashboard/src/app/(dashboard)/page.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/page.test.tsx index aaad0d072e5..f5baff0e727 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/page.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/page.test.tsx @@ -37,8 +37,8 @@ vi.mock("@/contexts/AuthContext", () => ({ }), })); vi.mock("@/app/(dashboard)/hooks/keys/useKeys", () => ({ useKeys: mockUseKeys })); -vi.mock("@/app/(dashboard)/api-keys/ApiKeysDashboard", () => ({ - default: () => , +vi.mock("@/app/(dashboard)/home/_components/HomePage", () => ({ + default: () => , })); vi.mock("@/components/common_components/LoadingScreen", () => ({ default: () => , @@ -86,7 +86,7 @@ describe("dashboard landing", () => { (role) => { state.userRole = role; render(); - expect(screen.getByTestId("api-keys-dashboard")).toBeInTheDocument(); + expect(screen.getByTestId("home-page")).toBeInTheDocument(); expect(screen.queryByTestId("loading-screen")).not.toBeInTheDocument(); expect(mockReplace).not.toHaveBeenCalled(); expect(mockUiHref).not.toHaveBeenCalledWith("connect"); @@ -96,7 +96,7 @@ describe("dashboard landing", () => { it("lands a user who already owns a key on the keys dashboard", () => { state.keys = [{ token: "sk-abc" }]; render(); - expect(screen.getByTestId("api-keys-dashboard")).toBeInTheDocument(); + expect(screen.getByTestId("home-page")).toBeInTheDocument(); expect(mockReplace).not.toHaveBeenCalled(); }); @@ -110,7 +110,7 @@ describe("dashboard landing", () => { render(); expect(mockReplace).toHaveBeenCalledWith("/mocked-ui/logs"); expect(screen.getByTestId("loading-screen")).toBeInTheDocument(); - expect(screen.queryByTestId("api-keys-dashboard")).not.toBeInTheDocument(); + expect(screen.queryByTestId("home-page")).not.toBeInTheDocument(); }); it("carries the MCP env-var deep link's other params through the legacy redirect", () => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/page.tsx index 3a38958dd66..94f4ba79a25 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/page.tsx @@ -1,6 +1,6 @@ "use client"; -import ApiKeysDashboard from "@/app/(dashboard)/api-keys/ApiKeysDashboard"; +import HomePage from "@/app/(dashboard)/home/_components/HomePage"; import LoadingScreen from "@/components/common_components/LoadingScreen"; import { proxyBaseUrl } from "@/components/networking"; import { useAuth } from "@/contexts/AuthContext"; @@ -89,7 +89,7 @@ function CreateKeyPageContent() { return ; } - return ; + return ; } export default function CreateKeyPage() { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/UsageOverview.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/UsageOverview.tsx index 8b7c2d5ec45..afd6e5ba48b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/UsageOverview.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/UsageOverview.tsx @@ -6,7 +6,7 @@ * matching colors. */ -import React, { type ReactNode, useMemo, useState } from "react"; +import { type ReactNode, useMemo, useState } from "react"; import type { DailyData } from "@/components/UsagePage/types"; import { KeyRound, Users } from "lucide-react"; import { StackedUsageChart, type StackedUsageScale } from "@/components/shared/charts"; @@ -14,18 +14,15 @@ import { bucketSeries, bucketTotals, labelForDate, - dailyTotals, - formatCompact, - formatLatency, formatMetricValue, - formatUsd, type Granularity, type OverviewTotals, type UsageMetric, } from "./overviewData"; import { BreakdownControls, Leaderboard, useBreakdown, type BreakdownState } from "./BreakdownChart"; import { cn } from "@/lib/cva.config"; -import { ChartSkeleton, Panel, PANEL_INSET_X, Segmented, Sparkline, Stat } from "./Primitives"; +import { ChartSkeleton, Panel, PANEL_INSET_X, Segmented } from "./Primitives"; +import UsageStatStrip from "./UsageStatStrip"; interface UsageOverviewProps { results: readonly DailyData[]; @@ -41,7 +38,6 @@ interface UsageOverviewProps { } const METRIC_NOUN: Record = { spend: "spend", tokens: "tokens", requests: "requests" }; -const BRAND = "#2b3fd6"; const GRANULARITY_OPTIONS = [ { value: "day", label: "Daily" }, { value: "week", label: "Weekly" }, @@ -69,57 +65,17 @@ export default function UsageOverview({ const { series: dailySeries, ranking } = useBreakdown(results, state, 8); const series = useMemo(() => bucketSeries(dailySeries, granularity), [dailySeries, granularity]); const totalsByBucket = useMemo(() => bucketTotals(series), [series]); - const daily = useMemo(() => dailyTotals(results), [results]); const format = (value: number) => formatMetricValue(value, state.metric); return ( - - }> - - - }> - - {!requestCountsPending && ( - - - {totals.successful.toLocaleString()} ok - - - 0 ? "text-destructive tabular-nums" : "tabular-nums"}> - {totals.failed.toLocaleString()} - {" "} - failed - - - )} - - }> - - - - - - - - - + ); } - -function StatCell({ children, trend }: { children: React.ReactNode; trend?: React.ReactNode }) { - return ( - - {children} - {trend} - - ); -} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/UsageStatStrip.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/UsageStatStrip.tsx new file mode 100644 index 00000000000..318b7758370 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/UsageStatStrip.tsx @@ -0,0 +1,90 @@ +"use client"; + +import React, { useMemo } from "react"; +import type { DailyData } from "@/components/UsagePage/types"; +import { cn } from "@/lib/cva.config"; +import { dailyTotals, formatCompact, formatLatency, formatUsd, type OverviewTotals } from "./overviewData"; +import { PANEL_INSET_X, Sparkline, Stat } from "./Primitives"; + +const BRAND = "#2b3fd6"; + +interface UsageStatStripProps { + results: readonly DailyData[]; + totals: OverviewTotals; + loading: boolean; + requestCountsPending: boolean; + budget: number | null; +} + +/** Spend, requests, tokens and averages for a range, each with a daily sparkline. */ +export default function UsageStatStrip({ + results, + totals, + loading, + requestCountsPending, + budget, +}: UsageStatStripProps) { + const daily = useMemo(() => dailyTotals(results), [results]); + return ( + + }> + + + }> + + {!requestCountsPending && ( + + + {totals.successful.toLocaleString()} ok + + + 0 ? "text-destructive tabular-nums" : "tabular-nums"}> + {totals.failed.toLocaleString()} + {" "} + failed + + + )} + + }> + + + + + + + + + + ); +} + +function StatCell({ children, trend }: { children: React.ReactNode; trend?: React.ReactNode }) { + return ( + + {children} + {trend} + + ); +} diff --git a/ui/litellm-dashboard/src/components/CommandPalette/utils.test.ts b/ui/litellm-dashboard/src/components/CommandPalette/utils.test.ts index 5bb61756f80..c05e4db8924 100644 --- a/ui/litellm-dashboard/src/components/CommandPalette/utils.test.ts +++ b/ui/litellm-dashboard/src/components/CommandPalette/utils.test.ts @@ -5,7 +5,7 @@ import { flattenNavItems, matchNavItems, paletteScopeForRoute, shortcutLabel, ty describe("paletteScopeForRoute", () => { it.each([ ["api-keys", "keys"], - ["", "keys"], + ["", "global"], ["teams", "global"], ] as const)("uses the expected palette scope for %s", (routeSegment, expected) => { expect(paletteScopeForRoute(routeSegment)).toBe(expected); diff --git a/ui/litellm-dashboard/src/components/CommandPalette/utils.ts b/ui/litellm-dashboard/src/components/CommandPalette/utils.ts index ad02e155b0e..396b92a817e 100644 --- a/ui/litellm-dashboard/src/components/CommandPalette/utils.ts +++ b/ui/litellm-dashboard/src/components/CommandPalette/utils.ts @@ -12,7 +12,7 @@ export interface PaletteNavItem { } export const paletteScopeForRoute = (routeSegment: string): PaletteScope => - routeSegment === "" || routeSegment === "api-keys" ? "keys" : "global"; + routeSegment === "api-keys" ? "keys" : "global"; const matchRank = (item: PaletteNavItem, query: string): number => { const label = item.label.toLowerCase(); diff --git a/ui/litellm-dashboard/src/components/DashboardHeader.test.tsx b/ui/litellm-dashboard/src/components/DashboardHeader.test.tsx index 822cec9deb3..6c64d09009c 100644 --- a/ui/litellm-dashboard/src/components/DashboardHeader.test.tsx +++ b/ui/litellm-dashboard/src/components/DashboardHeader.test.tsx @@ -59,11 +59,11 @@ describe("DashboardHeader breadcrumb", () => { expect(screen.getByText("Models + Endpoints")).toBeInTheDocument(); }); - it("titles the dashboard root as Virtual Keys", () => { + it("titles the dashboard root as Home", () => { state.pathname = "/ui/"; renderDashboardHeader(); - expect(screen.getByText("Virtual Keys")).toBeInTheDocument(); + expect(screen.getByText("Home")).toBeInTheDocument(); }); it("roots the breadcrumb in the AI Gateway selector (with a Chat option) and drops the static section crumb when the selector is available", async () => { diff --git a/ui/litellm-dashboard/src/components/leftnav.test.tsx b/ui/litellm-dashboard/src/components/leftnav.test.tsx index cdb74c73a80..7ef951e1041 100644 --- a/ui/litellm-dashboard/src/components/leftnav.test.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.test.tsx @@ -247,6 +247,7 @@ describe("Sidebar (leftnav)", () => { renderWithProviders(); const topLevelLabels = [ + "Home", "Virtual Keys", "Playground", "Models + Endpoints", @@ -660,10 +661,10 @@ describe("Sidebar (leftnav)", () => { expect(screen.getByRole("link", { name: "Virtual Keys" })).not.toHaveAttribute("data-active"); }); - it("marks Virtual Keys active at the dashboard root", () => { + it("marks Home active at the dashboard root", () => { navState.pathname = "/ui/"; renderWithProviders(); - expect(screen.getByRole("link", { name: "Virtual Keys" })).toHaveAttribute("data-active", "true"); + expect(screen.getByRole("link", { name: "Home" })).toHaveAttribute("data-active", "true"); }); it("expands the parent group of the current nested route and marks the child active", () => { @@ -745,8 +746,8 @@ describe("getBreadcrumb", () => { expect(getBreadcrumb("/ui/old-usage")).toEqual({ section: "Developer Tools", title: "Old Usage" }); }); - it("titles the dashboard root as Virtual Keys", () => { - expect(getBreadcrumb("/ui/")).toEqual({ section: "AI Gateway", title: "Virtual Keys" }); + it("titles the dashboard root as Home", () => { + expect(getBreadcrumb("/ui/")).toEqual({ section: "AI Gateway", title: "Home" }); }); it("resolves a nested child route to its parent section", () => { diff --git a/ui/litellm-dashboard/src/components/leftnav.tsx b/ui/litellm-dashboard/src/components/leftnav.tsx index 256fe578912..9a6ec334b56 100644 --- a/ui/litellm-dashboard/src/components/leftnav.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.tsx @@ -40,6 +40,7 @@ import { FlaskConical, Folder, HeartPulse, + House, KeyRound, LayoutGrid, Network, @@ -207,6 +208,7 @@ const menuGroups: MenuGroup[] = [ { groupLabel: "AI GATEWAY", items: [ + { key: "home", page: "home", label: "Home", icon: }, { key: "api-keys", page: "api-keys", label: "Virtual Keys", icon: }, { key: "llm-playground", @@ -468,7 +470,7 @@ const menuGroups: MenuGroup[] = [ }, ]; -const HOME_ROUTE = "api-keys"; +const HOME_ROUTE = "home"; export const routeOf = (item: MenuItem): string => item.route ?? item.page; diff --git a/ui/litellm-dashboard/src/components/page_metadata.ts b/ui/litellm-dashboard/src/components/page_metadata.ts index 8eb14fc2151..72bed431421 100644 --- a/ui/litellm-dashboard/src/components/page_metadata.ts +++ b/ui/litellm-dashboard/src/components/page_metadata.ts @@ -5,6 +5,7 @@ // Page descriptions for UI Settings configuration export const pageDescriptions: Record = { + home: "Usage at a glance, recent launches and blog updates", "api-keys": "Manage virtual keys for API access and authentication", "llm-playground": "Interactive playground for testing LLM requests", models: "Configure and manage LLM models and endpoints", diff --git a/ui/litellm-dashboard/tests/CreateKeyPage.expiredToken.test.tsx b/ui/litellm-dashboard/tests/CreateKeyPage.expiredToken.test.tsx index 2c57833102d..1a31d85eed5 100644 --- a/ui/litellm-dashboard/tests/CreateKeyPage.expiredToken.test.tsx +++ b/ui/litellm-dashboard/tests/CreateKeyPage.expiredToken.test.tsx @@ -105,7 +105,7 @@ vi.mock("@/utils/returnUrlUtils", async (importOriginal) => { // Super-light stubs for all heavy components so rendering doesn't explode vi.mock("@/components/navbar", () => ({ default: stub("navbar") })); -vi.mock("@/app/(dashboard)/api-keys/ApiKeysDashboard", () => ({ default: stub("api-keys-dashboard") })); +vi.mock("@/app/(dashboard)/home/_components/HomePage", () => ({ default: stub("home-page") })); vi.mock("@/components/templates/model_dashboard", () => ({ default: stub("model-dashboard") })); vi.mock("@/components/teams", () => ({ default: stub("teams") })); vi.mock("@/app/(dashboard)/organizations/_components/organizations", () => ({ @@ -271,7 +271,7 @@ describe("CreateKeyPage auth behavior", () => { // And the default page content appears (ApiKeysDashboard stub; chrome now lives in the layout) await waitFor(() => { - expect(screen.getByTestId("api-keys-dashboard")).toBeInTheDocument(); + expect(screen.getByTestId("home-page")).toBeInTheDocument(); }); });
{item.description}
No posts yet