From 1a358318f8821878ea4623bfd7cb4af2dee3da3f Mon Sep 17 00:00:00 2001 From: Kerry Lu Date: Wed, 9 Sep 2026 10:38:45 -0700 Subject: [PATCH 01/10] feat(ui): add upgrade banner with latest release changelog stats Adds GET /get/latest_release_info, which fetches the latest stable GitHub release once per hour per worker and buckets its PR bullets by conventional commit prefix into new features, fixes, and other updates. The Admin UI compares the running proxy version to that release and shows a dismissible top banner with the stat line when the proxy is behind. Dismissal is stored per release version in localStorage so the banner returns for the next one. Co-Authored-By: Claude Code --- litellm/proxy/proxy_server.py | 4 + .../latest_release_endpoints.py | 138 +++++++++++++ .../test_latest_release_endpoints.py | 191 ++++++++++++++++++ .../latestRelease/useLatestReleaseInfo.ts | 16 ++ .../src/app/(dashboard)/layout.tsx | 3 + .../src/components/UpgradeBanner.test.tsx | 108 ++++++++++ .../src/components/UpgradeBanner.tsx | 77 +++++++ ui/litellm-dashboard/src/lib/http/schema.d.ts | 54 +++++ .../src/utils/versionUtils.test.ts | 53 +++++ .../src/utils/versionUtils.ts | 23 +++ 10 files changed, 667 insertions(+) create mode 100644 litellm/proxy/ui_crud_endpoints/latest_release_endpoints.py create mode 100644 tests/test_litellm/proxy/ui_crud_endpoints/test_latest_release_endpoints.py create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/latestRelease/useLatestReleaseInfo.ts create mode 100644 ui/litellm-dashboard/src/components/UpgradeBanner.test.tsx create mode 100644 ui/litellm-dashboard/src/components/UpgradeBanner.tsx create mode 100644 ui/litellm-dashboard/src/utils/versionUtils.test.ts create mode 100644 ui/litellm-dashboard/src/utils/versionUtils.ts diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 7219b373dc3..65146e8744f 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -647,6 +647,9 @@ from litellm.proxy.spend_tracking.spend_management_endpoints import ( ) from litellm.proxy.spend_tracking.spend_tracking_utils import get_logging_payload from litellm.proxy.types_utils.utils import get_instance_fn +from litellm.proxy.ui_crud_endpoints.latest_release_endpoints import ( + router as latest_release_endpoints_router, +) from litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints import ( router as ui_crud_endpoints_router, ) @@ -18368,6 +18371,7 @@ app.include_router(debugging_endpoints_router) app.include_router(rust_control_plane_router) app.include_router(ui_crud_endpoints_router) app.include_router(user_banner_endpoints_router) +app.include_router(latest_release_endpoints_router) app.include_router(team_callback_router) app.include_router(budget_management_router) app.include_router(model_management_router) diff --git a/litellm/proxy/ui_crud_endpoints/latest_release_endpoints.py b/litellm/proxy/ui_crud_endpoints/latest_release_endpoints.py new file mode 100644 index 00000000000..c0306e65aee --- /dev/null +++ b/litellm/proxy/ui_crud_endpoints/latest_release_endpoints.py @@ -0,0 +1,138 @@ +import re +from collections import Counter +from collections.abc import Awaitable, Mapping +from dataclasses import dataclass +from types import MappingProxyType +from typing import Final, Literal, Protocol + +import httpx +from fastapi import APIRouter, Depends +from pydantic import BaseModel, ValidationError + +from litellm._logging import verbose_proxy_logger +from litellm.caching.in_memory_cache import InMemoryCache +from litellm.proxy.auth.user_api_key_auth import user_api_key_auth + +router: Final = APIRouter() + +LATEST_RELEASE_URL: Final = "https://api.github.com/repos/BerriAI/litellm/releases/latest" +LATEST_RELEASE_FETCH_TIMEOUT_SECONDS: Final = 5 +LATEST_RELEASE_CACHE_TTL_SECONDS: Final = 60 * 60 +LATEST_RELEASE_UNAVAILABLE_CACHE_TTL_SECONDS: Final = 5 * 60 +LATEST_RELEASE_CACHE_KEY: Final = "latest_release_info" + +_RELEASE_BULLET_PATTERN: Final = re.compile(r"^\*\s+([A-Za-z]+)(\([^)]*\))?!?:\s") + +_Bucket = Literal["new_features", "bug_fixes", "other_updates"] +_PREFIX_BUCKETS: Final[Mapping[str, _Bucket]] = MappingProxyType({"feat": "new_features", "fix": "bug_fixes"}) + + +class LatestReleaseInfo(BaseModel): + version: str + new_features: int + bug_fixes: int + other_updates: int + release_url: str + + +@dataclass(frozen=True, slots=True) +class LatestReleaseUnavailable: + reason: str + + +class _GitHubRelease(BaseModel): + tag_name: str + html_url: str + body: str + + +class _AsyncGetClient(Protocol): + def get(self, url: str, *, timeout: float | None = None) -> Awaitable[httpx.Response]: ... + + +_latest_release_cache: Final = InMemoryCache(max_size_in_memory=1, default_ttl=LATEST_RELEASE_CACHE_TTL_SECONDS) + + +def _default_client() -> _AsyncGetClient: + from litellm.llms.custom_httpx.http_handler import get_async_httpx_client + from litellm.types.llms.custom_http import httpxSpecialProvider + + return get_async_httpx_client(llm_provider=httpxSpecialProvider.UI) + + +def _default_cache() -> InMemoryCache: + return _latest_release_cache + + +def count_release_bullets(body: str) -> Counter[_Bucket]: + """ + Bucket a release body's ``* type(scope): title by @user in `` bullets by conventional-commit type. + Lines without that shape (headings, "New Contributors" entries) are skipped, not counted as other. + """ + return Counter( + _PREFIX_BUCKETS.get(match.group(1).lower(), "other_updates") + for line in body.splitlines() + if (match := _RELEASE_BULLET_PATTERN.match(line)) is not None + ) + + +def parse_latest_release(response: httpx.Response) -> LatestReleaseInfo | LatestReleaseUnavailable: + if response.status_code != 200: + return LatestReleaseUnavailable(reason=f"GitHub responded with status {response.status_code}") + try: + release: Final = _GitHubRelease.model_validate_json(response.content) + except ValidationError as e: + return LatestReleaseUnavailable(reason=f"GitHub release payload was not the expected shape: {e}") + counts: Final = count_release_bullets(release.body) + return LatestReleaseInfo( + version=release.tag_name.removeprefix("v"), + new_features=counts["new_features"], + bug_fixes=counts["bug_fixes"], + other_updates=counts["other_updates"], + release_url=release.html_url, + ) + + +async def fetch_latest_release(client: _AsyncGetClient) -> LatestReleaseInfo | LatestReleaseUnavailable: + try: + response: Final = await client.get(LATEST_RELEASE_URL, timeout=LATEST_RELEASE_FETCH_TIMEOUT_SECONDS) + except httpx.HTTPError as e: + return LatestReleaseUnavailable(reason=f"{type(e).__name__}: {e}") + return parse_latest_release(response) + + +async def get_latest_release_info( + client: _AsyncGetClient, cache: InMemoryCache +) -> LatestReleaseInfo | LatestReleaseUnavailable: + cached: Final = cache.get_cache(LATEST_RELEASE_CACHE_KEY) + if isinstance(cached, (LatestReleaseInfo, LatestReleaseUnavailable)): + return cached + result: Final = await fetch_latest_release(client) + ttl: Final = ( + LATEST_RELEASE_UNAVAILABLE_CACHE_TTL_SECONDS + if isinstance(result, LatestReleaseUnavailable) + else LATEST_RELEASE_CACHE_TTL_SECONDS + ) + cache.set_cache(LATEST_RELEASE_CACHE_KEY, result, ttl=ttl) + return result + + +@router.get( + "/get/latest_release_info", + tags=["UI Settings"], # mutable-ok: FastAPI's route decorator only accepts a list + dependencies=[Depends(user_api_key_auth)], # mutable-ok: FastAPI's route decorator only accepts a list + response_model=LatestReleaseInfo | None, +) +async def latest_release_info( + client: _AsyncGetClient = Depends(_default_client), + cache: InMemoryCache = Depends(_default_cache), +) -> LatestReleaseInfo | None: + """ + Latest stable LiteLLM GitHub release with its PR count split into new features, bug fixes and other updates. + Returns null when GitHub can't be reached so the dashboard upgrade banner simply doesn't render. + """ + result: Final = await get_latest_release_info(client=client, cache=cache) + if isinstance(result, LatestReleaseUnavailable): + verbose_proxy_logger.warning("LiteLLM: latest release info unavailable: %s", result.reason) + return None + return result diff --git a/tests/test_litellm/proxy/ui_crud_endpoints/test_latest_release_endpoints.py b/tests/test_litellm/proxy/ui_crud_endpoints/test_latest_release_endpoints.py new file mode 100644 index 00000000000..0bbcdfe317d --- /dev/null +++ b/tests/test_litellm/proxy/ui_crud_endpoints/test_latest_release_endpoints.py @@ -0,0 +1,191 @@ +import json +import time +from typing import Final + +import httpx +import pytest +from fastapi.testclient import TestClient + +from litellm.caching.in_memory_cache import InMemoryCache +from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth +from litellm.proxy.auth.user_api_key_auth import user_api_key_auth +from litellm.proxy.proxy_server import app +from litellm.proxy.ui_crud_endpoints.latest_release_endpoints import ( + LATEST_RELEASE_CACHE_KEY, + LATEST_RELEASE_CACHE_TTL_SECONDS, + LATEST_RELEASE_UNAVAILABLE_CACHE_TTL_SECONDS, + LATEST_RELEASE_URL, + LatestReleaseInfo, + LatestReleaseUnavailable, + _default_cache, + _default_client, + count_release_bullets, + get_latest_release_info, +) + +SAMPLE_BODY: Final = """## What's Changed +* feat(proxy): add upgrade banner by @kerry in https://github.com/BerriAI/litellm/pull/1 +* fix(azure): retry on 429 by @a in https://github.com/BerriAI/litellm/pull/2 +* fix: handle empty body by @b in https://github.com/BerriAI/litellm/pull/3 +* Feat(ui)!: drop legacy theme by @c in https://github.com/BerriAI/litellm/pull/4 +* chore(deps): bump httpx by @d in https://github.com/BerriAI/litellm/pull/5 +* docs: fix typo by @e in https://github.com/BerriAI/litellm/pull/6 +* Litellm dev 09 08 2026 by @f in https://github.com/BerriAI/litellm/pull/7 +* refactor(router) : spaced colon does not match by @g in https://github.com/BerriAI/litellm/pull/8 + +## New Contributors +* @kerry made their first contribution in https://github.com/BerriAI/litellm/pull/1 + +**Full Changelog**: https://github.com/BerriAI/litellm/compare/v1.101.0...v1.102.0 +""" + +SAMPLE_RELEASE: Final = { + "tag_name": "v1.102.0", + "html_url": "https://github.com/BerriAI/litellm/releases/tag/v1.102.0", + "body": SAMPLE_BODY, +} +EXPECTED_INFO: Final = { + "version": "1.102.0", + "new_features": 2, + "bug_fixes": 2, + "other_updates": 2, + "release_url": SAMPLE_RELEASE["html_url"], +} + + +class _RecordingClient: + def __init__(self, outcomes: list[httpx.Response | Exception]) -> None: + self._outcomes = outcomes + self.calls: list[tuple[str, float | None]] = [] + + async def get(self, url: str, *, timeout: float | None = None) -> httpx.Response: + self.calls.append((url, timeout)) + outcome = self._outcomes[min(len(self.calls) - 1, len(self._outcomes) - 1)] + if isinstance(outcome, Exception): + raise outcome + return outcome + + +def _github_response(status: int = 200, payload: object = SAMPLE_RELEASE) -> httpx.Response: + return httpx.Response(status, content=json.dumps(payload).encode()) + + +def _fresh_cache() -> InMemoryCache: + return InMemoryCache(max_size_in_memory=1, default_ttl=LATEST_RELEASE_CACHE_TTL_SECONDS) + + +def _override_dependencies(client: _RecordingClient, cache: InMemoryCache, role: LitellmUserRoles) -> None: + async def auth() -> UserAPIKeyAuth: + return UserAPIKeyAuth(api_key="sk-test", user_id="test-user", user_role=role) + + app.dependency_overrides[user_api_key_auth] = auth + app.dependency_overrides[_default_client] = lambda: client + app.dependency_overrides[_default_cache] = lambda: cache + + +@pytest.fixture +def http_client(): + yield TestClient(app) + app.dependency_overrides.pop(user_api_key_auth, None) + app.dependency_overrides.pop(_default_client, None) + app.dependency_overrides.pop(_default_cache, None) + + +class TestCountReleaseBullets: + def test_buckets_by_conventional_commit_type(self): + counts = count_release_bullets(SAMPLE_BODY) + assert counts["new_features"] == 2 + assert counts["bug_fixes"] == 2 + assert counts["other_updates"] == 2 + + def test_ignores_non_bullet_lines_and_contributor_entries(self): + assert ( + sum(count_release_bullets("## What's Changed\n\n* @x made their first contribution in url\n").values()) == 0 + ) + + def test_empty_body_yields_zero_counts(self): + counts = count_release_bullets("") + assert (counts["new_features"], counts["bug_fixes"], counts["other_updates"]) == (0, 0, 0) + + +class TestGetLatestReleaseInfo: + @pytest.mark.asyncio + async def test_fetches_and_parses_github_release(self): + client = _RecordingClient([_github_response()]) + result = await get_latest_release_info(client=client, cache=_fresh_cache()) + assert isinstance(result, LatestReleaseInfo) + assert result.model_dump() == EXPECTED_INFO + assert client.calls == [(LATEST_RELEASE_URL, 5)] + + @pytest.mark.asyncio + async def test_second_call_within_ttl_does_not_refetch(self): + client = _RecordingClient([_github_response()]) + cache = _fresh_cache() + first = await get_latest_release_info(client=client, cache=cache) + second = await get_latest_release_info(client=client, cache=cache) + assert first == second + assert len(client.calls) == 1 + + @pytest.mark.asyncio + async def test_success_is_cached_for_the_full_ttl(self): + cache = _fresh_cache() + await get_latest_release_info(client=_RecordingClient([_github_response()]), cache=cache) + remaining = await cache.async_get_ttl(LATEST_RELEASE_CACHE_KEY) - time.time() + assert LATEST_RELEASE_CACHE_TTL_SECONDS - 5 < remaining <= LATEST_RELEASE_CACHE_TTL_SECONDS + + @pytest.mark.asyncio + async def test_failure_is_cached_briefly_so_github_is_not_hammered(self): + client = _RecordingClient([httpx.ConnectError("boom")]) + cache = _fresh_cache() + first = await get_latest_release_info(client=client, cache=cache) + second = await get_latest_release_info(client=client, cache=cache) + assert isinstance(first, LatestReleaseUnavailable) + assert first == second + assert len(client.calls) == 1 + remaining = await cache.async_get_ttl(LATEST_RELEASE_CACHE_KEY) - time.time() + assert ( + LATEST_RELEASE_UNAVAILABLE_CACHE_TTL_SECONDS - 5 < remaining <= LATEST_RELEASE_UNAVAILABLE_CACHE_TTL_SECONDS + ) + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "response", + [ + _github_response(status=403, payload={"message": "rate limited"}), + _github_response(status=500, payload={}), + _github_response(payload={"tag_name": "v1.0.0"}), + httpx.Response(200, content=b"not json"), + ], + ids=["rate_limited", "server_error", "missing_fields", "not_json"], + ) + async def test_bad_github_responses_are_unavailable(self, response: httpx.Response): + result = await get_latest_release_info(client=_RecordingClient([response]), cache=_fresh_cache()) + assert isinstance(result, LatestReleaseUnavailable) + + +class TestLatestReleaseInfoEndpoint: + def test_returns_release_stats_for_authenticated_user(self, http_client): + _override_dependencies(_RecordingClient([_github_response()]), _fresh_cache(), LitellmUserRoles.INTERNAL_USER) + response = http_client.get("/get/latest_release_info") + assert response.status_code == 200 + assert response.json() == EXPECTED_INFO + + def test_returns_null_when_github_is_unreachable(self, http_client): + _override_dependencies( + _RecordingClient([httpx.ConnectError("boom")]), _fresh_cache(), LitellmUserRoles.PROXY_ADMIN + ) + response = http_client.get("/get/latest_release_info") + assert response.status_code == 200 + assert response.json() is None + + def test_repeated_requests_reuse_cache(self, http_client): + client = _RecordingClient([_github_response()]) + _override_dependencies(client, _fresh_cache(), LitellmUserRoles.PROXY_ADMIN) + assert http_client.get("/get/latest_release_info").json() == EXPECTED_INFO + assert http_client.get("/get/latest_release_info").json() == EXPECTED_INFO + assert len(client.calls) == 1 + + def test_rejects_unauthenticated_requests(self, monkeypatch): + monkeypatch.setattr("litellm.proxy.proxy_server.master_key", "sk-1234") + response = TestClient(app).get("/get/latest_release_info") + assert response.status_code in (401, 403) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/latestRelease/useLatestReleaseInfo.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/latestRelease/useLatestReleaseInfo.ts new file mode 100644 index 00000000000..5186baa605c --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/latestRelease/useLatestReleaseInfo.ts @@ -0,0 +1,16 @@ +import { $api } from "@/lib/http/api"; +import type { components } from "@/lib/http/schema"; + +export type LatestReleaseInfo = components["schemas"]["LatestReleaseInfo"]; + +export const useLatestReleaseInfo = (accessToken: string | null | undefined) => + $api.useQuery( + "get", + "/get/latest_release_info", + {}, + { + enabled: Boolean(accessToken), + staleTime: 60 * 60 * 1000, + retry: false, + }, + ); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx b/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx index 98f2a36d6f3..928fc5f2410 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx @@ -12,6 +12,7 @@ import { DebugWarningBanner } from "@/components/DebugWarningBanner"; import { NoRedisWarningBanner } from "@/components/NoRedisWarningBanner"; import { LicenseExpiryBanner } from "@/components/LicenseExpiryBanner"; import { UserBanner } from "@/components/UserBanner"; +import { UpgradeBanner } from "@/components/UpgradeBanner"; import { uiHref } from "@/utils/uiHref"; import { PluginModeProvider, usePluginMode } from "@/contexts/PluginModeContext"; import { createApiClient } from "@/lib/http/client"; @@ -115,6 +116,7 @@ function DashboardShell({ children }: { children: React.ReactNode }) { +
@@ -134,6 +136,7 @@ function DashboardShell({ children }: { children: React.ReactNode }) { +
{children}
diff --git a/ui/litellm-dashboard/src/components/UpgradeBanner.test.tsx b/ui/litellm-dashboard/src/components/UpgradeBanner.test.tsx new file mode 100644 index 00000000000..be031ea8ad5 --- /dev/null +++ b/ui/litellm-dashboard/src/components/UpgradeBanner.test.tsx @@ -0,0 +1,108 @@ +import React from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { describeRelease, UpgradeBanner, UpgradeBannerView } from "./UpgradeBanner"; +import type { LatestReleaseInfo } from "@/app/(dashboard)/hooks/latestRelease/useLatestReleaseInfo"; + +vi.mock("@/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails", () => ({ + useHealthReadinessDetails: vi.fn(), +})); +vi.mock("@/app/(dashboard)/hooks/latestRelease/useLatestReleaseInfo", () => ({ + useLatestReleaseInfo: vi.fn(), +})); + +import { useHealthReadinessDetails } from "@/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails"; +import { useLatestReleaseInfo } from "@/app/(dashboard)/hooks/latestRelease/useLatestReleaseInfo"; + +const RELEASE: LatestReleaseInfo = { + version: "1.103.0", + new_features: 12, + bug_fixes: 30, + other_updates: 8, + release_url: "https://github.com/BerriAI/litellm/releases/tag/v1.103.0", +}; + +describe("describeRelease", () => { + it("lists features, fixes, and other updates in the agreed order", () => { + expect(describeRelease(RELEASE)).toBe("12 new features, 30 fixes, and 8 other updates"); + }); + + it("singularises counts of one", () => { + expect(describeRelease({ ...RELEASE, new_features: 1, bug_fixes: 1, other_updates: 1 })).toBe( + "1 new feature, 1 fix, and 1 other update", + ); + }); +}); + +describe("UpgradeBannerView", () => { + beforeEach(() => { + localStorage.clear(); + }); + + afterEach(() => { + localStorage.clear(); + }); + + it("renders nothing while either version is unknown", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + const { container: noRelease } = render(); + expect(noRelease).toBeEmptyDOMElement(); + }); + + it("renders nothing when the running version is up to date or ahead", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + const { container: ahead } = render(); + expect(ahead).toBeEmptyDOMElement(); + }); + + it("shows the latest version, the stat line, and the current version when behind", () => { + render(); + const alert = screen.getByRole("alert"); + expect(alert).toHaveTextContent("The latest version is v1.103.0: 12 new features, 30 fixes, and 8 other updates"); + expect(alert).toHaveTextContent("Your current version is v1.102.0"); + expect(screen.getByRole("link", { name: "v1.103.0" })).toHaveAttribute("href", RELEASE.release_url); + }); + + it("dismissing hides the banner and keeps it hidden on remount for the same release", () => { + const { unmount } = render(); + fireEvent.click(screen.getByRole("button", { name: "Close" })); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + unmount(); + + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it("reappears once a newer release ships after a dismissal", () => { + const { unmount } = render(); + fireEvent.click(screen.getByRole("button", { name: "Close" })); + unmount(); + + render(); + expect(screen.getByRole("alert")).toHaveTextContent("The latest version is v1.104.0"); + }); +}); + +describe("UpgradeBanner", () => { + beforeEach(() => { + localStorage.clear(); + }); + + it("feeds both hooks the access token and renders from their data", () => { + vi.mocked(useHealthReadinessDetails).mockReturnValue({ data: { litellm_version: "1.102.0" } } as any); + vi.mocked(useLatestReleaseInfo).mockReturnValue({ data: RELEASE } as any); + render(); + expect(useHealthReadinessDetails).toHaveBeenCalledWith("token"); + expect(useLatestReleaseInfo).toHaveBeenCalledWith("token"); + expect(screen.getByRole("alert")).toHaveTextContent("The latest version is v1.103.0"); + }); + + it("renders nothing when the release endpoint returns null", () => { + vi.mocked(useHealthReadinessDetails).mockReturnValue({ data: { litellm_version: "1.102.0" } } as any); + vi.mocked(useLatestReleaseInfo).mockReturnValue({ data: null } as any); + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/UpgradeBanner.tsx b/ui/litellm-dashboard/src/components/UpgradeBanner.tsx new file mode 100644 index 00000000000..cc57223eaff --- /dev/null +++ b/ui/litellm-dashboard/src/components/UpgradeBanner.tsx @@ -0,0 +1,77 @@ +"use client"; + +import React, { useState } from "react"; +import { ArrowUpCircle, X } from "lucide-react"; +import { Alert, AlertAction, AlertDescription, AlertTitle } from "@/components/shared/Alert"; +import { Button } from "@/components/ui/button"; +import { useHealthReadinessDetails } from "@/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails"; +import { + type LatestReleaseInfo, + useLatestReleaseInfo, +} from "@/app/(dashboard)/hooks/latestRelease/useLatestReleaseInfo"; +import { getLocalStorageItem, setLocalStorageItem } from "@/utils/localStorageUtils"; +import { isNewerVersion } from "@/utils/versionUtils"; + +const DISMISS_KEY_PREFIX = "litellm:upgradeBannerDismissed:"; + +interface UpgradeBannerProps { + accessToken: string | null; +} + +interface UpgradeBannerViewProps { + currentVersion: string | null | undefined; + latestRelease: LatestReleaseInfo | null | undefined; +} + +const plural = (count: number, singular: string, pluralForm: string): string => + `${count} ${count === 1 ? singular : pluralForm}`; + +export const describeRelease = ({ new_features, bug_fixes, other_updates }: LatestReleaseInfo): string => + [ + plural(new_features, "new feature", "new features"), + plural(bug_fixes, "fix", "fixes"), + `and ${plural(other_updates, "other update", "other updates")}`, + ].join(", "); + +export const UpgradeBannerView: React.FC = ({ currentVersion, latestRelease }) => { + const [locallyDismissed, setLocallyDismissed] = useState(false); + + if (!currentVersion || !latestRelease || !isNewerVersion(currentVersion, latestRelease.version)) { + return null; + } + + const dismissKey = `${DISMISS_KEY_PREFIX}${latestRelease.version}`; + if (locallyDismissed || getLocalStorageItem(dismissKey) === "true") { + return null; + } + + const handleClose = () => { + setLocalStorageItem(dismissKey, "true"); + setLocallyDismissed(true); + }; + + return ( + + + + The latest version is{" "} + + v{latestRelease.version} + + : {describeRelease(latestRelease)} + + Your current version is v{currentVersion} + + + + + ); +}; + +export const UpgradeBanner: React.FC = ({ accessToken }) => { + const { data: healthData } = useHealthReadinessDetails(accessToken); + const { data: latestRelease } = useLatestReleaseInfo(accessToken); + return ; +}; diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 83b0d58f2b2..cd12fd10528 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -5109,6 +5109,27 @@ export interface paths { patch?: never; trace?: never; }; + "/get/latest_release_info": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Latest Release Info + * @description Latest stable LiteLLM GitHub release with its PR count split into new features, bug fixes and other updates. + * Returns null when GitHub can't be reached so the dashboard upgrade banner simply doesn't render. + */ + get: operations["latest_release_info_get_latest_release_info_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/get/mcp_semantic_filter_settings": { parameters: { query?: never; @@ -28352,6 +28373,19 @@ export interface components { } & { [key: string]: unknown; }; + /** LatestReleaseInfo */ + LatestReleaseInfo: { + /** Bug Fixes */ + bug_fixes: number; + /** New Features */ + new_features: number; + /** Other Updates */ + other_updates: number; + /** Release Url */ + release_url: string; + /** Version */ + version: string; + }; /** ListAccessGroupsResponse */ ListAccessGroupsResponse: { /** Access Groups */ @@ -47428,6 +47462,26 @@ export interface operations { }; }; }; + latest_release_info_get_latest_release_info_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["LatestReleaseInfo"] | null; + }; + }; + }; + }; get_mcp_semantic_filter_settings_get_mcp_semantic_filter_settings_get: { parameters: { query?: never; diff --git a/ui/litellm-dashboard/src/utils/versionUtils.test.ts b/ui/litellm-dashboard/src/utils/versionUtils.test.ts new file mode 100644 index 00000000000..714c7165d6d --- /dev/null +++ b/ui/litellm-dashboard/src/utils/versionUtils.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { isNewerVersion, parseReleaseVersion } from "./versionUtils"; + +describe("parseReleaseVersion", () => { + it("reads the numeric components with or without a leading v", () => { + expect(parseReleaseVersion("1.102.0")).toEqual([1, 102, 0]); + expect(parseReleaseVersion("v1.102.0")).toEqual([1, 102, 0]); + }); + + it("ignores a prerelease suffix", () => { + expect(parseReleaseVersion("1.102.0-dev.3")).toEqual([1, 102, 0]); + expect(parseReleaseVersion("1.102.0.rc1")).toEqual([1, 102, 0]); + }); + + it("returns null for strings that are not a release version", () => { + expect(parseReleaseVersion("")).toBeNull(); + expect(parseReleaseVersion("latest")).toBeNull(); + expect(parseReleaseVersion("1.102")).toBeNull(); + }); +}); + +describe("isNewerVersion", () => { + it("is false when the versions are equal", () => { + expect(isNewerVersion("1.102.0", "1.102.0")).toBe(false); + expect(isNewerVersion("1.102.0", "v1.102.0")).toBe(false); + }); + + it("is true when the latest version is ahead on any component", () => { + expect(isNewerVersion("1.102.0", "1.102.1")).toBe(true); + expect(isNewerVersion("1.102.5", "1.103.0")).toBe(true); + expect(isNewerVersion("1.999.9", "2.0.0")).toBe(true); + }); + + it("is false when the running version is already ahead", () => { + expect(isNewerVersion("1.103.0", "1.102.9")).toBe(false); + expect(isNewerVersion("2.0.0", "1.999.9")).toBe(false); + }); + + it("compares components numerically rather than as strings", () => { + expect(isNewerVersion("1.9.0", "1.10.0")).toBe(true); + expect(isNewerVersion("1.10.0", "1.9.0")).toBe(false); + }); + + it("treats a prerelease of the latest version as not behind", () => { + expect(isNewerVersion("1.102.0-dev.1", "1.102.0")).toBe(false); + expect(isNewerVersion("1.101.0-dev.1", "1.102.0")).toBe(true); + }); + + it("is false when either version cannot be parsed", () => { + expect(isNewerVersion("unknown", "1.102.0")).toBe(false); + expect(isNewerVersion("1.102.0", "")).toBe(false); + }); +}); diff --git a/ui/litellm-dashboard/src/utils/versionUtils.ts b/ui/litellm-dashboard/src/utils/versionUtils.ts new file mode 100644 index 00000000000..62fcb97c0ef --- /dev/null +++ b/ui/litellm-dashboard/src/utils/versionUtils.ts @@ -0,0 +1,23 @@ +const RELEASE_VERSION_PATTERN = /^v?(\d+)\.(\d+)\.(\d+)/; + +export const parseReleaseVersion = (version: string): readonly [number, number, number] | null => { + const match = RELEASE_VERSION_PATTERN.exec(version.trim()); + if (!match) { + return null; + } + return [Number(match[1]), Number(match[2]), Number(match[3])]; +}; + +export const isNewerVersion = (current: string, latest: string): boolean => { + const currentParts = parseReleaseVersion(current); + const latestParts = parseReleaseVersion(latest); + if (!currentParts || !latestParts) { + return false; + } + for (let i = 0; i < 3; i += 1) { + if (latestParts[i] !== currentParts[i]) { + return latestParts[i] > currentParts[i]; + } + } + return false; +}; From 7edc79583139309ef7571e3bb5b77828d9cb535e Mon Sep 17 00:00:00 2001 From: Kerry Lu Date: Wed, 9 Sep 2026 15:21:39 -0700 Subject: [PATCH 02/10] fix(ui): fix CI failures on upgrade banner PR Use Annotated[..., Depends(...)] instead of a call in the default value to clear the B008 ruff budget. Extract an inline test object over the no-large-inline-object-arg eslint budget. Mock UpgradeBanner in the layout test, matching the other top banners, since it now renders through react-query hooks that need a QueryClientProvider the test doesn't set up. Co-Authored-By: Claude Code --- litellm/proxy/ui_crud_endpoints/latest_release_endpoints.py | 6 +++--- ui/litellm-dashboard/src/app/(dashboard)/layout.test.tsx | 4 ++++ ui/litellm-dashboard/src/components/UpgradeBanner.test.tsx | 5 ++--- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/litellm/proxy/ui_crud_endpoints/latest_release_endpoints.py b/litellm/proxy/ui_crud_endpoints/latest_release_endpoints.py index c0306e65aee..4a01728dfe1 100644 --- a/litellm/proxy/ui_crud_endpoints/latest_release_endpoints.py +++ b/litellm/proxy/ui_crud_endpoints/latest_release_endpoints.py @@ -3,7 +3,7 @@ from collections import Counter from collections.abc import Awaitable, Mapping from dataclasses import dataclass from types import MappingProxyType -from typing import Final, Literal, Protocol +from typing import Annotated, Final, Literal, Protocol import httpx from fastapi import APIRouter, Depends @@ -124,8 +124,8 @@ async def get_latest_release_info( response_model=LatestReleaseInfo | None, ) async def latest_release_info( - client: _AsyncGetClient = Depends(_default_client), - cache: InMemoryCache = Depends(_default_cache), + client: Annotated[_AsyncGetClient, Depends(_default_client)], + cache: Annotated[InMemoryCache, Depends(_default_cache)], ) -> LatestReleaseInfo | None: """ Latest stable LiteLLM GitHub release with its PR count split into new features, bug fixes and other updates. diff --git a/ui/litellm-dashboard/src/app/(dashboard)/layout.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/layout.test.tsx index 7f1f4cc4bd5..e1fb6e21596 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/layout.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/layout.test.tsx @@ -37,6 +37,10 @@ vi.mock("@/components/UserBanner", () => ({ UserBanner: () => null, })); +vi.mock("@/components/UpgradeBanner", () => ({ + UpgradeBanner: () => null, +})); + vi.mock("@/contexts/ThemeContext", () => ({ ThemeProvider: ({ children }: { children: React.ReactNode }) => <>{children}, })); diff --git a/ui/litellm-dashboard/src/components/UpgradeBanner.test.tsx b/ui/litellm-dashboard/src/components/UpgradeBanner.test.tsx index be031ea8ad5..6aa384600c8 100644 --- a/ui/litellm-dashboard/src/components/UpgradeBanner.test.tsx +++ b/ui/litellm-dashboard/src/components/UpgradeBanner.test.tsx @@ -28,9 +28,8 @@ describe("describeRelease", () => { }); it("singularises counts of one", () => { - expect(describeRelease({ ...RELEASE, new_features: 1, bug_fixes: 1, other_updates: 1 })).toBe( - "1 new feature, 1 fix, and 1 other update", - ); + const singularCounts = { ...RELEASE, new_features: 1, bug_fixes: 1, other_updates: 1 }; + expect(describeRelease(singularCounts)).toBe("1 new feature, 1 fix, and 1 other update"); }); }); From 4036b769a952ace55b802d317e335881a921d3b4 Mon Sep 17 00:00:00 2001 From: kerry Date: Sat, 19 Sep 2026 00:22:15 +0000 Subject: [PATCH 03/10] fix(proxy): count unprefixed release bullets and coalesce concurrent latest release fetches Unprefixed release bullets now count as other_updates, concurrent cache misses share one upstream GitHub request through an injected asyncio.Lock, and the dashboard upgrade banner is announced as status rather than alert Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../latest_release_endpoints.py | 55 +++++++----- .../test_latest_release_endpoints.py | 89 ++++++++++++++++--- .../src/components/UpgradeBanner.test.tsx | 32 +++++-- .../src/components/UpgradeBanner.tsx | 2 +- 4 files changed, 139 insertions(+), 39 deletions(-) diff --git a/litellm/proxy/ui_crud_endpoints/latest_release_endpoints.py b/litellm/proxy/ui_crud_endpoints/latest_release_endpoints.py index 4a01728dfe1..61124e9f27e 100644 --- a/litellm/proxy/ui_crud_endpoints/latest_release_endpoints.py +++ b/litellm/proxy/ui_crud_endpoints/latest_release_endpoints.py @@ -1,3 +1,4 @@ +import asyncio import re from collections import Counter from collections.abc import Awaitable, Mapping @@ -21,7 +22,8 @@ LATEST_RELEASE_CACHE_TTL_SECONDS: Final = 60 * 60 LATEST_RELEASE_UNAVAILABLE_CACHE_TTL_SECONDS: Final = 5 * 60 LATEST_RELEASE_CACHE_KEY: Final = "latest_release_info" -_RELEASE_BULLET_PATTERN: Final = re.compile(r"^\*\s+([A-Za-z]+)(\([^)]*\))?!?:\s") +_RELEASE_BULLET_PATTERN: Final = re.compile(r"^\*\s+(?:([A-Za-z]+)(?:\([^)]*\))?!?:\s)?\S") +_NEW_CONTRIBUTOR_PATTERN: Final = re.compile(r"^\*\s+@\S+ made their first contribution\b") _Bucket = Literal["new_features", "bug_fixes", "other_updates"] _PREFIX_BUCKETS: Final[Mapping[str, _Bucket]] = MappingProxyType({"feat": "new_features", "fix": "bug_fixes"}) @@ -51,6 +53,7 @@ class _AsyncGetClient(Protocol): _latest_release_cache: Final = InMemoryCache(max_size_in_memory=1, default_ttl=LATEST_RELEASE_CACHE_TTL_SECONDS) +_latest_release_fetch_lock: Final = asyncio.Lock() def _default_client() -> _AsyncGetClient: @@ -64,16 +67,23 @@ def _default_cache() -> InMemoryCache: return _latest_release_cache +def _default_fetch_lock() -> asyncio.Lock: + return _latest_release_fetch_lock + + +def _bucket_for(line: str) -> _Bucket | None: + if _NEW_CONTRIBUTOR_PATTERN.match(line) is not None: + return None + match: Final = _RELEASE_BULLET_PATTERN.match(line) + if match is None: + return None + prefix: Final = match.group(1) + return "other_updates" if prefix is None else _PREFIX_BUCKETS.get(prefix.lower(), "other_updates") + + def count_release_bullets(body: str) -> Counter[_Bucket]: - """ - Bucket a release body's ``* type(scope): title by @user in `` bullets by conventional-commit type. - Lines without that shape (headings, "New Contributors" entries) are skipped, not counted as other. - """ - return Counter( - _PREFIX_BUCKETS.get(match.group(1).lower(), "other_updates") - for line in body.splitlines() - if (match := _RELEASE_BULLET_PATTERN.match(line)) is not None - ) + """Bucket release-note bullets by conventional-commit type or ``other_updates``.""" + return Counter(bucket for line in body.splitlines() if (bucket := _bucket_for(line)) is not None) def parse_latest_release(response: httpx.Response) -> LatestReleaseInfo | LatestReleaseUnavailable: @@ -102,19 +112,23 @@ async def fetch_latest_release(client: _AsyncGetClient) -> LatestReleaseInfo | L async def get_latest_release_info( - client: _AsyncGetClient, cache: InMemoryCache + client: _AsyncGetClient, cache: InMemoryCache, fetch_lock: asyncio.Lock ) -> LatestReleaseInfo | LatestReleaseUnavailable: cached: Final = cache.get_cache(LATEST_RELEASE_CACHE_KEY) if isinstance(cached, (LatestReleaseInfo, LatestReleaseUnavailable)): return cached - result: Final = await fetch_latest_release(client) - ttl: Final = ( - LATEST_RELEASE_UNAVAILABLE_CACHE_TTL_SECONDS - if isinstance(result, LatestReleaseUnavailable) - else LATEST_RELEASE_CACHE_TTL_SECONDS - ) - cache.set_cache(LATEST_RELEASE_CACHE_KEY, result, ttl=ttl) - return result + async with fetch_lock: + cached_after_lock: Final = cache.get_cache(LATEST_RELEASE_CACHE_KEY) + if isinstance(cached_after_lock, (LatestReleaseInfo, LatestReleaseUnavailable)): + return cached_after_lock + result: Final = await fetch_latest_release(client) + ttl: Final = ( + LATEST_RELEASE_UNAVAILABLE_CACHE_TTL_SECONDS + if isinstance(result, LatestReleaseUnavailable) + else LATEST_RELEASE_CACHE_TTL_SECONDS + ) + cache.set_cache(LATEST_RELEASE_CACHE_KEY, result, ttl=ttl) + return result @router.get( @@ -126,12 +140,13 @@ async def get_latest_release_info( async def latest_release_info( client: Annotated[_AsyncGetClient, Depends(_default_client)], cache: Annotated[InMemoryCache, Depends(_default_cache)], + fetch_lock: Annotated[asyncio.Lock, Depends(_default_fetch_lock)], ) -> LatestReleaseInfo | None: """ Latest stable LiteLLM GitHub release with its PR count split into new features, bug fixes and other updates. Returns null when GitHub can't be reached so the dashboard upgrade banner simply doesn't render. """ - result: Final = await get_latest_release_info(client=client, cache=cache) + result: Final = await get_latest_release_info(client=client, cache=cache, fetch_lock=fetch_lock) if isinstance(result, LatestReleaseUnavailable): verbose_proxy_logger.warning("LiteLLM: latest release info unavailable: %s", result.reason) return None diff --git a/tests/test_litellm/proxy/ui_crud_endpoints/test_latest_release_endpoints.py b/tests/test_litellm/proxy/ui_crud_endpoints/test_latest_release_endpoints.py index 0bbcdfe317d..c966b8b7135 100644 --- a/tests/test_litellm/proxy/ui_crud_endpoints/test_latest_release_endpoints.py +++ b/tests/test_litellm/proxy/ui_crud_endpoints/test_latest_release_endpoints.py @@ -1,3 +1,4 @@ +import asyncio import json import time from typing import Final @@ -19,6 +20,7 @@ from litellm.proxy.ui_crud_endpoints.latest_release_endpoints import ( LatestReleaseUnavailable, _default_cache, _default_client, + _default_fetch_lock, count_release_bullets, get_latest_release_info, ) @@ -48,7 +50,7 @@ EXPECTED_INFO: Final = { "version": "1.102.0", "new_features": 2, "bug_fixes": 2, - "other_updates": 2, + "other_updates": 4, "release_url": SAMPLE_RELEASE["html_url"], } @@ -81,6 +83,7 @@ def _override_dependencies(client: _RecordingClient, cache: InMemoryCache, role: app.dependency_overrides[user_api_key_auth] = auth app.dependency_overrides[_default_client] = lambda: client app.dependency_overrides[_default_cache] = lambda: cache + app.dependency_overrides[_default_fetch_lock] = lambda: asyncio.Lock() @pytest.fixture @@ -89,6 +92,7 @@ def http_client(): app.dependency_overrides.pop(user_api_key_auth, None) app.dependency_overrides.pop(_default_client, None) app.dependency_overrides.pop(_default_cache, None) + app.dependency_overrides.pop(_default_fetch_lock, None) class TestCountReleaseBullets: @@ -96,11 +100,21 @@ class TestCountReleaseBullets: counts = count_release_bullets(SAMPLE_BODY) assert counts["new_features"] == 2 assert counts["bug_fixes"] == 2 - assert counts["other_updates"] == 2 + assert counts["other_updates"] == 4 + + def test_unprefixed_bullets_count_as_other_updates(self): + counts = count_release_bullets("* Litellm dev 09 08 2026 by @f in https://x/pull/7\n") + assert (counts["new_features"], counts["bug_fixes"], counts["other_updates"]) == (0, 0, 1) def test_ignores_non_bullet_lines_and_contributor_entries(self): assert ( - sum(count_release_bullets("## What's Changed\n\n* @x made their first contribution in url\n").values()) == 0 + sum( + count_release_bullets( + "## What's Changed\n\n* @x made their first contribution in url\n" + "\n**Full Changelog**: https://github.com/BerriAI/litellm/compare/v1...v2\n" + ).values() + ) + == 0 ) def test_empty_body_yields_zero_counts(self): @@ -112,7 +126,7 @@ class TestGetLatestReleaseInfo: @pytest.mark.asyncio async def test_fetches_and_parses_github_release(self): client = _RecordingClient([_github_response()]) - result = await get_latest_release_info(client=client, cache=_fresh_cache()) + result = await get_latest_release_info(client=client, cache=_fresh_cache(), fetch_lock=asyncio.Lock()) assert isinstance(result, LatestReleaseInfo) assert result.model_dump() == EXPECTED_INFO assert client.calls == [(LATEST_RELEASE_URL, 5)] @@ -121,15 +135,18 @@ class TestGetLatestReleaseInfo: async def test_second_call_within_ttl_does_not_refetch(self): client = _RecordingClient([_github_response()]) cache = _fresh_cache() - first = await get_latest_release_info(client=client, cache=cache) - second = await get_latest_release_info(client=client, cache=cache) + fetch_lock = asyncio.Lock() + first = await get_latest_release_info(client=client, cache=cache, fetch_lock=fetch_lock) + second = await get_latest_release_info(client=client, cache=cache, fetch_lock=fetch_lock) assert first == second assert len(client.calls) == 1 @pytest.mark.asyncio async def test_success_is_cached_for_the_full_ttl(self): cache = _fresh_cache() - await get_latest_release_info(client=_RecordingClient([_github_response()]), cache=cache) + await get_latest_release_info( + client=_RecordingClient([_github_response()]), cache=cache, fetch_lock=asyncio.Lock() + ) remaining = await cache.async_get_ttl(LATEST_RELEASE_CACHE_KEY) - time.time() assert LATEST_RELEASE_CACHE_TTL_SECONDS - 5 < remaining <= LATEST_RELEASE_CACHE_TTL_SECONDS @@ -137,8 +154,9 @@ class TestGetLatestReleaseInfo: async def test_failure_is_cached_briefly_so_github_is_not_hammered(self): client = _RecordingClient([httpx.ConnectError("boom")]) cache = _fresh_cache() - first = await get_latest_release_info(client=client, cache=cache) - second = await get_latest_release_info(client=client, cache=cache) + fetch_lock = asyncio.Lock() + first = await get_latest_release_info(client=client, cache=cache, fetch_lock=fetch_lock) + second = await get_latest_release_info(client=client, cache=cache, fetch_lock=fetch_lock) assert isinstance(first, LatestReleaseUnavailable) assert first == second assert len(client.calls) == 1 @@ -159,9 +177,60 @@ class TestGetLatestReleaseInfo: ids=["rate_limited", "server_error", "missing_fields", "not_json"], ) async def test_bad_github_responses_are_unavailable(self, response: httpx.Response): - result = await get_latest_release_info(client=_RecordingClient([response]), cache=_fresh_cache()) + result = await get_latest_release_info( + client=_RecordingClient([response]), cache=_fresh_cache(), fetch_lock=asyncio.Lock() + ) assert isinstance(result, LatestReleaseUnavailable) + @pytest.mark.asyncio + async def test_concurrent_misses_share_one_fetch(self): + event = asyncio.Event() + + class _BlockingClient(_RecordingClient): + async def get(self, url: str, *, timeout: float | None = None) -> httpx.Response: + self.calls.append((url, timeout)) + await event.wait() + return _github_response() + + client = _BlockingClient([]) + cache = _fresh_cache() + fetch_lock = asyncio.Lock() + tasks = [ + asyncio.create_task(get_latest_release_info(client=client, cache=cache, fetch_lock=fetch_lock)) + for _ in range(5) + ] + await asyncio.sleep(0) + await asyncio.sleep(0) + event.set() + results = await asyncio.gather(*tasks) + expected: Final = LatestReleaseInfo.model_validate(EXPECTED_INFO) + assert results == [expected] * 5 + assert len(client.calls) == 1 + + @pytest.mark.asyncio + async def test_failure_under_lock_is_also_coalesced(self): + event = asyncio.Event() + + class _FailingBlockingClient(_RecordingClient): + async def get(self, url: str, *, timeout: float | None = None) -> httpx.Response: + self.calls.append((url, timeout)) + await event.wait() + raise httpx.ConnectError("boom") + + client = _FailingBlockingClient([]) + cache = _fresh_cache() + fetch_lock = asyncio.Lock() + tasks = [ + asyncio.create_task(get_latest_release_info(client=client, cache=cache, fetch_lock=fetch_lock)) + for _ in range(5) + ] + await asyncio.sleep(0) + await asyncio.sleep(0) + event.set() + results = await asyncio.gather(*tasks) + assert all(isinstance(result, LatestReleaseUnavailable) for result in results) + assert len(client.calls) == 1 + class TestLatestReleaseInfoEndpoint: def test_returns_release_stats_for_authenticated_user(self, http_client): diff --git a/ui/litellm-dashboard/src/components/UpgradeBanner.test.tsx b/ui/litellm-dashboard/src/components/UpgradeBanner.test.tsx index 6aa384600c8..831030fee80 100644 --- a/ui/litellm-dashboard/src/components/UpgradeBanner.test.tsx +++ b/ui/litellm-dashboard/src/components/UpgradeBanner.test.tsx @@ -58,7 +58,7 @@ describe("UpgradeBannerView", () => { it("shows the latest version, the stat line, and the current version when behind", () => { render(); - const alert = screen.getByRole("alert"); + const alert = screen.getByRole("status"); expect(alert).toHaveTextContent("The latest version is v1.103.0: 12 new features, 30 fixes, and 8 other updates"); expect(alert).toHaveTextContent("Your current version is v1.102.0"); expect(screen.getByRole("link", { name: "v1.103.0" })).toHaveAttribute("href", RELEASE.release_url); @@ -67,7 +67,7 @@ describe("UpgradeBannerView", () => { it("dismissing hides the banner and keeps it hidden on remount for the same release", () => { const { unmount } = render(); fireEvent.click(screen.getByRole("button", { name: "Close" })); - expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); unmount(); const { container } = render(); @@ -80,7 +80,7 @@ describe("UpgradeBannerView", () => { unmount(); render(); - expect(screen.getByRole("alert")).toHaveTextContent("The latest version is v1.104.0"); + expect(screen.getByRole("status")).toHaveTextContent("The latest version is v1.104.0"); }); }); @@ -89,18 +89,34 @@ describe("UpgradeBanner", () => { localStorage.clear(); }); + afterEach(() => { + localStorage.clear(); + }); + it("feeds both hooks the access token and renders from their data", () => { - vi.mocked(useHealthReadinessDetails).mockReturnValue({ data: { litellm_version: "1.102.0" } } as any); - vi.mocked(useLatestReleaseInfo).mockReturnValue({ data: RELEASE } as any); + const healthReadinessResult = { + data: { litellm_version: "1.102.0" }, + } as Partial> as ReturnType; + const latestReleaseResult = { data: RELEASE } as Partial> as ReturnType< + typeof useLatestReleaseInfo + >; + vi.mocked(useHealthReadinessDetails).mockReturnValue(healthReadinessResult); + vi.mocked(useLatestReleaseInfo).mockReturnValue(latestReleaseResult); render(); expect(useHealthReadinessDetails).toHaveBeenCalledWith("token"); expect(useLatestReleaseInfo).toHaveBeenCalledWith("token"); - expect(screen.getByRole("alert")).toHaveTextContent("The latest version is v1.103.0"); + expect(screen.getByRole("status")).toHaveTextContent("The latest version is v1.103.0"); }); it("renders nothing when the release endpoint returns null", () => { - vi.mocked(useHealthReadinessDetails).mockReturnValue({ data: { litellm_version: "1.102.0" } } as any); - vi.mocked(useLatestReleaseInfo).mockReturnValue({ data: null } as any); + const healthReadinessResult = { + data: { litellm_version: "1.102.0" }, + } as Partial> as ReturnType; + const latestReleaseResult = { data: null } as Partial> as ReturnType< + typeof useLatestReleaseInfo + >; + vi.mocked(useHealthReadinessDetails).mockReturnValue(healthReadinessResult); + vi.mocked(useLatestReleaseInfo).mockReturnValue(latestReleaseResult); const { container } = render(); expect(container).toBeEmptyDOMElement(); }); diff --git a/ui/litellm-dashboard/src/components/UpgradeBanner.tsx b/ui/litellm-dashboard/src/components/UpgradeBanner.tsx index cc57223eaff..d990c5112a4 100644 --- a/ui/litellm-dashboard/src/components/UpgradeBanner.tsx +++ b/ui/litellm-dashboard/src/components/UpgradeBanner.tsx @@ -51,7 +51,7 @@ export const UpgradeBannerView: React.FC = ({ currentVer }; return ( - + The latest version is{" "} From 26bcc537a5d07044423d75aa75550bfafc56a982 Mon Sep 17 00:00:00 2001 From: kerry Date: Sat, 19 Sep 2026 00:44:10 +0000 Subject: [PATCH 04/10] fix(proxy): allow latest release info and reset banner dismissal Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/_types.py | 1 + .../proxy/auth/test_route_checks.py | 27 +++++++++++++++++++ .../src/components/UpgradeBanner.test.tsx | 8 ++++++ .../src/components/UpgradeBanner.tsx | 6 ++--- 4 files changed, 39 insertions(+), 3 deletions(-) diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 76a51627d0c..66a00e14f15 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -633,6 +633,7 @@ class LiteLLMRoutes(enum.Enum): "/v1/models", "/sso/get/ui_settings", "/get/user_banner", + "/get/latest_release_info", ] # NOTE: ROUTES ONLY FOR MASTER KEY - only the Master Key should be able to Reset Spend diff --git a/tests/test_litellm/proxy/auth/test_route_checks.py b/tests/test_litellm/proxy/auth/test_route_checks.py index 72c59223549..df5ac4ac135 100644 --- a/tests/test_litellm/proxy/auth/test_route_checks.py +++ b/tests/test_litellm/proxy/auth/test_route_checks.py @@ -120,6 +120,33 @@ def test_user_banner_read_open_to_non_admin_roles(role): ) +@pytest.mark.parametrize( + "role", + [ + LitellmUserRoles.INTERNAL_USER.value, + LitellmUserRoles.INTERNAL_USER_VIEW_ONLY.value, + ], +) +def test_latest_release_info_read_open_to_non_admin_roles(role): + user_obj = LiteLLM_UserTable( + user_id="test_user", + user_email="test@example.com", + user_role=role, + ) + valid_token = UserAPIKeyAuth(user_id="test_user", user_role=role) + request = MagicMock(spec=Request) + request.query_params = {} + + RouteChecks.non_proxy_admin_allowed_routes_check( + user_obj=user_obj, + _user_role=role, + route="/get/latest_release_info", + request=request, + valid_token=valid_token, + request_data={}, + ) + + def test_user_banner_update_rejected_for_non_admin(): """Publishing the banner stays admin-only at the route layer.""" user_obj = LiteLLM_UserTable( diff --git a/ui/litellm-dashboard/src/components/UpgradeBanner.test.tsx b/ui/litellm-dashboard/src/components/UpgradeBanner.test.tsx index 831030fee80..4466597394c 100644 --- a/ui/litellm-dashboard/src/components/UpgradeBanner.test.tsx +++ b/ui/litellm-dashboard/src/components/UpgradeBanner.test.tsx @@ -82,6 +82,14 @@ describe("UpgradeBannerView", () => { render(); expect(screen.getByRole("status")).toHaveTextContent("The latest version is v1.104.0"); }); + + it("shows a newer release after the current one was dismissed without remounting", () => { + const { rerender } = render(); + fireEvent.click(screen.getByRole("button", { name: "Close" })); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + rerender(); + expect(screen.getByRole("status")).toHaveTextContent("The latest version is v1.104.0"); + }); }); describe("UpgradeBanner", () => { diff --git a/ui/litellm-dashboard/src/components/UpgradeBanner.tsx b/ui/litellm-dashboard/src/components/UpgradeBanner.tsx index d990c5112a4..101a6e3410d 100644 --- a/ui/litellm-dashboard/src/components/UpgradeBanner.tsx +++ b/ui/litellm-dashboard/src/components/UpgradeBanner.tsx @@ -34,20 +34,20 @@ export const describeRelease = ({ new_features, bug_fixes, other_updates }: Late ].join(", "); export const UpgradeBannerView: React.FC = ({ currentVersion, latestRelease }) => { - const [locallyDismissed, setLocallyDismissed] = useState(false); + const [dismissedVersion, setDismissedVersion] = useState(null); if (!currentVersion || !latestRelease || !isNewerVersion(currentVersion, latestRelease.version)) { return null; } const dismissKey = `${DISMISS_KEY_PREFIX}${latestRelease.version}`; - if (locallyDismissed || getLocalStorageItem(dismissKey) === "true") { + if (dismissedVersion === latestRelease.version || getLocalStorageItem(dismissKey) === "true") { return null; } const handleClose = () => { setLocalStorageItem(dismissKey, "true"); - setLocallyDismissed(true); + setDismissedVersion(latestRelease.version); }; return ( From ea04267912f5066e8de7b857626d9588bdac39cd Mon Sep 17 00:00:00 2001 From: kerry Date: Sun, 20 Sep 2026 09:30:20 +0000 Subject: [PATCH 05/10] fix(proxy): return an immutable bucket mapping from count_release_bullets Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../ui_crud_endpoints/latest_release_endpoints.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/litellm/proxy/ui_crud_endpoints/latest_release_endpoints.py b/litellm/proxy/ui_crud_endpoints/latest_release_endpoints.py index 61124e9f27e..ad5cc8efc31 100644 --- a/litellm/proxy/ui_crud_endpoints/latest_release_endpoints.py +++ b/litellm/proxy/ui_crud_endpoints/latest_release_endpoints.py @@ -4,7 +4,7 @@ from collections import Counter from collections.abc import Awaitable, Mapping from dataclasses import dataclass from types import MappingProxyType -from typing import Annotated, Final, Literal, Protocol +from typing import Annotated, Final, Literal, Protocol, TypeAlias import httpx from fastapi import APIRouter, Depends @@ -25,7 +25,7 @@ LATEST_RELEASE_CACHE_KEY: Final = "latest_release_info" _RELEASE_BULLET_PATTERN: Final = re.compile(r"^\*\s+(?:([A-Za-z]+)(?:\([^)]*\))?!?:\s)?\S") _NEW_CONTRIBUTOR_PATTERN: Final = re.compile(r"^\*\s+@\S+ made their first contribution\b") -_Bucket = Literal["new_features", "bug_fixes", "other_updates"] +_Bucket: TypeAlias = Literal["new_features", "bug_fixes", "other_updates"] _PREFIX_BUCKETS: Final[Mapping[str, _Bucket]] = MappingProxyType({"feat": "new_features", "fix": "bug_fixes"}) @@ -81,9 +81,9 @@ def _bucket_for(line: str) -> _Bucket | None: return "other_updates" if prefix is None else _PREFIX_BUCKETS.get(prefix.lower(), "other_updates") -def count_release_bullets(body: str) -> Counter[_Bucket]: +def count_release_bullets(body: str) -> Mapping[_Bucket, int]: """Bucket release-note bullets by conventional-commit type or ``other_updates``.""" - return Counter(bucket for line in body.splitlines() if (bucket := _bucket_for(line)) is not None) + return MappingProxyType(Counter(bucket for line in body.splitlines() if (bucket := _bucket_for(line)) is not None)) def parse_latest_release(response: httpx.Response) -> LatestReleaseInfo | LatestReleaseUnavailable: @@ -96,9 +96,9 @@ def parse_latest_release(response: httpx.Response) -> LatestReleaseInfo | Latest counts: Final = count_release_bullets(release.body) return LatestReleaseInfo( version=release.tag_name.removeprefix("v"), - new_features=counts["new_features"], - bug_fixes=counts["bug_fixes"], - other_updates=counts["other_updates"], + new_features=counts.get("new_features", 0), + bug_fixes=counts.get("bug_fixes", 0), + other_updates=counts.get("other_updates", 0), release_url=release.html_url, ) From 3777b0b0d9393b8739d126893203a9080b8038bf Mon Sep 17 00:00:00 2001 From: kerry Date: Sun, 20 Sep 2026 09:54:29 +0000 Subject: [PATCH 06/10] test(proxy): document why the release info route check asserts by not raising Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- tests/test_litellm/proxy/auth/test_route_checks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_litellm/proxy/auth/test_route_checks.py b/tests/test_litellm/proxy/auth/test_route_checks.py index ff9995df1ca..87bf4595af5 100644 --- a/tests/test_litellm/proxy/auth/test_route_checks.py +++ b/tests/test_litellm/proxy/auth/test_route_checks.py @@ -127,7 +127,7 @@ def test_user_banner_read_open_to_non_admin_roles(role): LitellmUserRoles.INTERNAL_USER_VIEW_ONLY.value, ], ) -def test_latest_release_info_read_open_to_non_admin_roles(role): +def test_latest_release_info_read_open_to_non_admin_roles(role): # test-quality-ok: allowed path returns None, not raising is the observable user_obj = LiteLLM_UserTable( user_id="test_user", user_email="test@example.com", From a909a7908ed56f92b6c1d22283e7be0f86d1a36e Mon Sep 17 00:00:00 2001 From: kerry Date: Mon, 21 Sep 2026 20:01:58 +0000 Subject: [PATCH 07/10] fix(fal_ai): surface fal errors in video status and content Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../llms/base_llm/videos/transformation.py | 13 ++ litellm/llms/custom_httpx/llm_http_handler.py | 2 +- litellm/llms/fal_ai/videos/transformation.py | 183 ++++++++++++++-- tests/integration/contracts.json | 3 + .../providers/test_fal_ai_video_wire.py | 53 +++++ .../test_fal_ai_video_transformation.py | 200 +++++++++++++++++- 6 files changed, 423 insertions(+), 31 deletions(-) diff --git a/litellm/llms/base_llm/videos/transformation.py b/litellm/llms/base_llm/videos/transformation.py index f725b295d0f..a765d493347 100644 --- a/litellm/llms/base_llm/videos/transformation.py +++ b/litellm/llms/base_llm/videos/transformation.py @@ -272,6 +272,19 @@ class BaseVideoConfig(ABC): ) -> VideoObject: pass + async def async_transform_video_status_retrieve_response( + self, + raw_response: httpx.Response, + logging_obj: LiteLLMLoggingObj, + custom_llm_provider: str | None = None, + ) -> VideoObject: + """Async transform video status retrieve response.""" + return self.transform_video_status_retrieve_response( + raw_response=raw_response, + logging_obj=logging_obj, + custom_llm_provider=custom_llm_provider, + ) + def transform_video_create_character_request( self, name: str, diff --git a/litellm/llms/custom_httpx/llm_http_handler.py b/litellm/llms/custom_httpx/llm_http_handler.py index 49a332e62bb..7f1ff5298ba 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -8881,7 +8881,7 @@ class BaseLLMHTTPHandler: url=url, headers=headers, ) - return video_status_provider_config.transform_video_status_retrieve_response( + return await video_status_provider_config.async_transform_video_status_retrieve_response( raw_response=response, logging_obj=logging_obj, custom_llm_provider=custom_llm_provider, diff --git a/litellm/llms/fal_ai/videos/transformation.py b/litellm/llms/fal_ai/videos/transformation.py index 8a355b3d226..130e5bda70a 100644 --- a/litellm/llms/fal_ai/videos/transformation.py +++ b/litellm/llms/fal_ai/videos/transformation.py @@ -1,7 +1,7 @@ import math import sys import time -from collections.abc import Mapping +from collections.abc import Mapping, Sequence from dataclasses import dataclass from types import MappingProxyType from typing import Final, TypeAlias @@ -163,11 +163,87 @@ def _response_data(raw_response: httpx.Response) -> Mapping[str, object]: return TypeAdapter(Mapping[str, object]).validate_python(raw_response.json()) +def _response_data_or_none(raw_response: httpx.Response) -> Mapping[str, object] | None: + try: + return _response_data(raw_response) + except ValueError: + return None + + +def _detail_item_text(item: Mapping[str, object]) -> str | None: + message: Final[object] = item.get("msg") + if not isinstance(message, str): + return None + location: Final[object] = item.get("loc") + if isinstance(location, str) and location: + return f"{location}: {message}" + if isinstance(location, (list, tuple)): + location_parts: Final[tuple[str, ...]] = tuple(part for part in location if isinstance(part, str)) + if location_parts: + return f"{'.'.join(location_parts)}: {message}" + return message + + +def _error_text(response_data: Mapping[str, object]) -> str | None: + detail: Final[object] = response_data.get("detail") + if isinstance(detail, str): + return detail + if isinstance(detail, list): + detail_items: Final[Sequence[Mapping[str, object]]] = TypeAdapter( + Sequence[Mapping[str, object]] + ).validate_python(tuple(item for item in detail if isinstance(item, Mapping))) + detail_messages: Final[tuple[str, ...]] = tuple( + message for item in detail_items if (message := _detail_item_text(item)) is not None + ) + if detail_messages: + return "; ".join(detail_messages) + error: Final[object] = response_data.get("error") + return error if isinstance(error, str) else None + + +def _result_error(raw_response: httpx.Response) -> str | None: + if raw_response.is_success: + return None + response_data: Final[Mapping[str, object] | None] = _response_data_or_none(raw_response) + error_text: Final[str | None] = _error_text(response_data) if response_data is not None else None + if error_text: + return error_text + response_text: Final[str] = raw_response.text + return response_text or f"fal.ai returned HTTP {raw_response.status_code}" + + def _response_string(response_data: Mapping[str, object], key: str, default: str = "") -> str: value: Final[object] = response_data.get(key) return value if isinstance(value, str) else default +def _status_video_object( + response_data: Mapping[str, object], + raw_response: httpx.Response, + custom_llm_provider: str | None, + result_error: str | None, +) -> VideoObject: + raw_status: Final[str] = _response_string(response_data, "status", "IN_QUEUE") + status: Final[str] = _STATUS_MAP.get(raw_status, "queued") + status_error: Final[str | None] = _error_text(response_data) + error: Final[str | None] = result_error if result_error is not None else status_error + provider: Final[str] = custom_llm_provider or _FAL_AI_PROVIDER + model_path: Final[str | None] = _model_path_from_request_url(raw_response) + request_id: Final[str] = _response_string(response_data, "request_id") or ( + _request_id_from_request_url(raw_response) or "" + ) + return VideoObject( + id=encode_video_id_with_provider(request_id, provider, model_path), + object="video", + status="failed" if error else status, + created_at=0, + model=model_path, + error=( + {"code": "fal_error", "message": error} if error else None # mutable-ok: VideoObject requires a dict + ), + ) + + class FalAIVideoConfig(BaseVideoConfig): def get_supported_openai_params(self, model: str) -> _SupportedParams: supported_params: Final[_SupportedParams] = [ # mutable-ok: BaseVideoConfig requires a list @@ -345,25 +421,77 @@ class FalAIVideoConfig(BaseVideoConfig): custom_llm_provider: str | None = None, ) -> VideoObject: response_data: Final[Mapping[str, object]] = _response_data(raw_response) - raw_status: Final[str] = _response_string(response_data, "status", "IN_QUEUE") - status: Final[str] = _STATUS_MAP.get(raw_status, "queued") - error_value: Final[object] = response_data.get("error") - error: Final[str | None] = error_value if isinstance(error_value, str) else None - provider: Final[str] = custom_llm_provider or _FAL_AI_PROVIDER - model_path: Final[str | None] = _model_path_from_request_url(raw_response) - request_id: Final[str] = _response_string(response_data, "request_id") or ( - _request_id_from_request_url(raw_response) or "" + result_error: Final[str | None] = self._fetch_result_error(raw_response, response_data) + return _status_video_object( + response_data=response_data, + raw_response=raw_response, + custom_llm_provider=custom_llm_provider, + result_error=result_error, ) - return VideoObject( - id=encode_video_id_with_provider(request_id, provider, model_path), - object="video", - status="failed" if error else status, - created_at=0, - model=model_path, - error=( - {"code": "fal_error", "message": error} if error else None # mutable-ok: VideoObject requires a dict - ), + + def _fetch_result_error( + self, + raw_response: httpx.Response, + response_data: Mapping[str, object], + ) -> str | None: + if _response_string(response_data, "status", "IN_QUEUE") != "COMPLETED": + return None + result_url: Final[str] = str(raw_response.request.url).removesuffix("/status") + result_headers: Final[Mapping[str, str]] = MappingProxyType( + { + key: value + for key, value in ( + ("Authorization", raw_response.request.headers.get("Authorization")), + ("Content-Type", raw_response.request.headers.get("Content-Type")), + ) + if value is not None + } ) + result_response: Final[httpx.Response] = _get_httpx_client().get( + url=result_url, + headers=result_headers, + ) + return _result_error(result_response) + + async def async_transform_video_status_retrieve_response( + self, + raw_response: httpx.Response, + logging_obj: object, + custom_llm_provider: str | None = None, + ) -> VideoObject: + response_data: Final[Mapping[str, object]] = _response_data(raw_response) + result_error: Final[str | None] = await self._fetch_result_error_async(raw_response, response_data) + return _status_video_object( + response_data=response_data, + raw_response=raw_response, + custom_llm_provider=custom_llm_provider, + result_error=result_error, + ) + + async def _fetch_result_error_async( + self, + raw_response: httpx.Response, + response_data: Mapping[str, object], + ) -> str | None: + if _response_string(response_data, "status", "IN_QUEUE") != "COMPLETED": + return None + result_url: Final[str] = str(raw_response.request.url).removesuffix("/status") + result_headers: Final[Mapping[str, str]] = MappingProxyType( + { + key: value + for key, value in ( + ("Authorization", raw_response.request.headers.get("Authorization")), + ("Content-Type", raw_response.request.headers.get("Content-Type")), + ) + if value is not None + } + ) + async_httpx_client: Final[AsyncHTTPHandler] = get_async_httpx_client(llm_provider=LlmProviders.FAL_AI) + result_response: Final[httpx.Response] = await async_httpx_client.get( + url=result_url, + headers=result_headers, + ) + return _result_error(result_response) @staticmethod def _decode_video_id(video_id: str) -> tuple[str, str]: @@ -401,15 +529,19 @@ class FalAIVideoConfig(BaseVideoConfig): video_url: Final[object] = video_data.get("url") if isinstance(video_url, str) and video_url: return video_url - error_message: Final[str | None] = next( - (value for key in ("error", "detail") if isinstance(value := response_data.get(key), str)), - None, - ) + error_message: Final[str | None] = _error_text(response_data) if error_message: raise ValueError(f"fal.ai video result did not include a video URL: {error_message}") raise ValueError("fal.ai video result did not include a video URL") def transform_video_content_response(self, raw_response: httpx.Response, logging_obj: object) -> bytes: + error: Final[str | None] = _result_error(raw_response) + if error is not None: + raise FalAIVideoError( + status_code=raw_response.status_code, + message=error, + headers=dict(raw_response.headers), # mutable-ok: exception headers require a mutable dictionary + ) video_url: Final[str] = self._extract_video_url(_response_data(raw_response)) httpx_client: Final[HTTPHandler] = _get_httpx_client() video_response: Final[httpx.Response] = httpx_client.get( # pyright: ignore[reportUnknownMemberType] # HTTP handler stubs are untyped @@ -419,6 +551,13 @@ class FalAIVideoConfig(BaseVideoConfig): return video_response.content async def async_transform_video_content_response(self, raw_response: httpx.Response, logging_obj: object) -> bytes: + error: Final[str | None] = _result_error(raw_response) + if error is not None: + raise FalAIVideoError( + status_code=raw_response.status_code, + message=error, + headers=dict(raw_response.headers), # mutable-ok: exception headers require a mutable dictionary + ) video_url: Final[str] = self._extract_video_url(_response_data(raw_response)) async_httpx_client: Final[AsyncHTTPHandler] = get_async_httpx_client(llm_provider=LlmProviders.FAL_AI) video_response: Final[httpx.Response] = await async_httpx_client.get( # pyright: ignore[reportUnknownMemberType] # HTTP handler stubs are untyped diff --git a/tests/integration/contracts.json b/tests/integration/contracts.json index 365456c0cec..96fa63a633f 100644 --- a/tests/integration/contracts.json +++ b/tests/integration/contracts.json @@ -166,6 +166,9 @@ "tests/integration/providers/test_fal_ai_video_wire.py::test_fal_video_create_status_and_content_follow_queue_wire_contract": [ "other.provider_wire.fal_ai.video_queue_create_status_and_content_download" ], + "tests/integration/providers/test_fal_ai_video_wire.py::test_fal_video_failed_result_reports_failed_status_and_fal_error": [ + "other.provider_wire.fal_ai.video_failed_result_surfaces_fal_error" + ], "tests/integration/providers/test_fal_ai_image_wire.py::test_fal_gpt_image_25_generation_sends_quality_and_size_and_charges_keyed_row": [ "other.provider_wire.fal_ai.gpt_image_generation_quality_size_wire_and_keyed_pricing" ], diff --git a/tests/integration/providers/test_fal_ai_video_wire.py b/tests/integration/providers/test_fal_ai_video_wire.py index ceb53c77c83..827818c6780 100644 --- a/tests/integration/providers/test_fal_ai_video_wire.py +++ b/tests/integration/providers/test_fal_ai_video_wire.py @@ -66,6 +66,7 @@ def test_fal_video_create_status_and_content_follow_queue_wire_contract(gateway: ("POST", f"/{_MODEL}"), ("GET", f"/bytedance/seedance-2.5/requests/{request_id}/status"), ("GET", f"/bytedance/seedance-2.5/requests/{request_id}"), + ("GET", f"/bytedance/seedance-2.5/requests/{request_id}"), ("GET", f"/files/{request_id}.mp4"), ] @@ -119,5 +120,57 @@ def test_fal_h3_video_create_uses_canonical_body_and_status_path(gateway: Gatewa ("POST", f"/{_H3_MODEL}"), ("GET", f"/minimax/h3/requests/{request_id}/status"), ("GET", f"/minimax/h3/requests/{request_id}"), + ("GET", f"/minimax/h3/requests/{request_id}"), ("GET", f"/files/{request_id}.mp4"), ] + + +@pytest.mark.covers("other.provider_wire.fal_ai.video_failed_result_surfaces_fal_error") +def test_fal_video_failed_result_reports_failed_status_and_fal_error(gateway: Gateway) -> None: + request_id: Final = "fal-failed-req-" + uuid.uuid4().hex + error_body: Final = { + "detail": [ + { + "loc": ["body", "input.reference_image_urls"], + "msg": "Failed to download the file. Please check if the URL is accessible and try again.", + "type": "file_download_error", + } + ] + } + + def respond(request: Request) -> Reply: + assert request.headers["authorization"] == "Key synthetic-fal-key" + if request.method == "POST": + assert request.target == f"/{_MODEL}" + return Reply( + body=json.dumps({"status": "IN_QUEUE", "request_id": request_id, "queue_position": 0}).encode() + ) + assert request.method == "GET" + if request.target == f"/bytedance/seedance-2.5/requests/{request_id}/status": + return Reply(body=json.dumps({"status": "COMPLETED", "request_id": request_id}).encode()) + assert request.target == f"/bytedance/seedance-2.5/requests/{request_id}" + return Reply(status=422, body=json.dumps(error_body).encode()) + + with wire_server(respond) as wire, gateway.scenario() as scenario: + model: Final = scenario.model( + model=f"fal_ai/{_MODEL}", + api_base=wire.url, + api_key="synthetic-fal-key", + ) + created: Final = gateway.post( + "/v1/videos", + { + "model": model, + "prompt": "a cat playing volleyball on a beach", + "seconds": "4", + "size": "1280x720", + }, + ) + assert created["status"] == "queued" + video_id: Final = created["id"] + status: Final = gateway.get(f"/v1/videos/{video_id}") + assert status["status"] == "failed" + assert "input.reference_image_urls: Failed to download the file" in status["error"]["message"] + content: Final = gateway.request("GET", f"/v1/videos/{video_id}/content") + assert content.status_code == 422, content.text + assert "Failed to download the file" in content.text diff --git a/tests/test_litellm/llms/fal_ai/videos/test_fal_ai_video_transformation.py b/tests/test_litellm/llms/fal_ai/videos/test_fal_ai_video_transformation.py index 963ed7eac47..570ff883eec 100644 --- a/tests/test_litellm/llms/fal_ai/videos/test_fal_ai_video_transformation.py +++ b/tests/test_litellm/llms/fal_ai/videos/test_fal_ai_video_transformation.py @@ -1,4 +1,5 @@ -from unittest.mock import Mock +from typing import Final +from unittest.mock import AsyncMock, Mock import httpx import pytest @@ -215,9 +216,18 @@ class TestFalAIVideoTransformation: ({"request_id": "abc", "status": "COMPLETED"}, "completed"), ], ) - def test_status_response_mapping(self, response_data, expected_status): + def test_status_response_mapping(self, response_data, expected_status, monkeypatch): status_url = "https://queue.fal.run/bytedance/seedance-2.5/requests/abc/status" response = httpx.Response(200, json=response_data, request=httpx.Request("GET", status_url)) + if expected_status == "completed": + result_response: Final = httpx.Response( + 200, + json={"video": {"url": "https://cdn.example.com/video.mp4"}}, + request=httpx.Request("GET", status_url.removesuffix("/status")), + ) + client: Final = Mock() + client.get.return_value = result_response + monkeypatch.setattr(fal_video_module, "_get_httpx_client", lambda: client) video = self.config.transform_video_status_retrieve_response( raw_response=response, @@ -239,20 +249,26 @@ class TestFalAIVideoTransformation: ) assert poll_url == status_url - def test_status_response_error(self): + def test_status_response_error(self, monkeypatch): response_data = { "request_id": "abc", "status": "COMPLETED", "error": "generation failed", } + status_url = "https://queue.fal.run/bytedance/seedance-2.5/requests/abc/status" response = httpx.Response( 200, json=response_data, - request=httpx.Request( - "GET", - "https://queue.fal.run/bytedance/seedance-2.5/requests/abc/status", - ), + request=httpx.Request("GET", status_url), ) + result_response: Final = httpx.Response( + 200, + json={"video": {"url": "https://cdn.example.com/video.mp4"}}, + request=httpx.Request("GET", status_url.removesuffix("/status")), + ) + client: Final = Mock() + client.get.return_value = result_response + monkeypatch.setattr(fal_video_module, "_get_httpx_client", lambda: client) video = self.config.transform_video_status_retrieve_response( raw_response=response, @@ -263,8 +279,99 @@ class TestFalAIVideoTransformation: assert video.status == "failed" assert video.error == {"code": "fal_error", "message": "generation failed"} - def test_status_response_uses_namespaced_request_url(self): + def test_status_completed_result_error_surfaces_fal_message(self, monkeypatch): + status_url = "https://queue.fal.run/minimax/h3/requests/abc/status" + auth_headers: Final = {"Authorization": "Key synthetic-fal-key", "Content-Type": "application/json"} + response: Final = httpx.Response( + 200, + json={"request_id": "abc", "status": "COMPLETED"}, + request=httpx.Request("GET", status_url, headers=auth_headers), + ) + result_url: Final = status_url.removesuffix("/status") + result_response: Final = httpx.Response( + 422, + json={ + "detail": [ + { + "loc": ["body", "input.reference_image_urls"], + "msg": "Failed to download the file. Please check if the URL is accessible and try again.", + } + ] + }, + request=httpx.Request("GET", result_url, headers=auth_headers), + ) + client: Final = Mock() + client.get.return_value = result_response + monkeypatch.setattr(fal_video_module, "_get_httpx_client", lambda: client) + + video = self.config.transform_video_status_retrieve_response( + raw_response=response, + logging_obj=self.logging_obj, + custom_llm_provider="fal_ai", + ) + + assert video.status == "failed" + assert "input.reference_image_urls: Failed to download the file" in video.error["message"] + client.get.assert_called_once_with(url=result_url, headers=auth_headers) + + @pytest.mark.asyncio + async def test_async_status_completed_result_error_surfaces_fal_message(self, monkeypatch): + status_url = "https://queue.fal.run/minimax/h3/requests/abc/status" + auth_headers: Final = {"Authorization": "Key synthetic-fal-key", "Content-Type": "application/json"} + response: Final = httpx.Response( + 200, + json={"request_id": "abc", "status": "COMPLETED"}, + request=httpx.Request("GET", status_url, headers=auth_headers), + ) + result_url: Final = status_url.removesuffix("/status") + result_response: Final = httpx.Response( + 422, + json={ + "detail": [ + { + "loc": ["body", "input.reference_image_urls"], + "msg": "Failed to download the file. Please check if the URL is accessible and try again.", + } + ] + }, + request=httpx.Request("GET", result_url, headers=auth_headers), + ) + client: Final = Mock() + client.get = AsyncMock(return_value=result_response) + monkeypatch.setattr(fal_video_module, "get_async_httpx_client", lambda llm_provider: client) + + video = await self.config.async_transform_video_status_retrieve_response( + raw_response=response, + logging_obj=self.logging_obj, + custom_llm_provider="fal_ai", + ) + + assert video.status == "failed" + assert "input.reference_image_urls: Failed to download the file" in video.error["message"] + client.get.assert_awaited_once_with(url=result_url, headers=auth_headers) + + def test_status_in_progress_does_not_fetch_result(self, monkeypatch): + status_url = "https://queue.fal.run/minimax/h3/requests/abc/status" response = httpx.Response( + 200, + json={"request_id": "abc", "status": "IN_PROGRESS"}, + request=httpx.Request("GET", status_url), + ) + + client: Final = Mock() + monkeypatch.setattr(fal_video_module, "_get_httpx_client", lambda: client) + + video = self.config.transform_video_status_retrieve_response( + raw_response=response, + logging_obj=self.logging_obj, + custom_llm_provider="fal_ai", + ) + + assert video.status == "in_progress" + client.get.assert_not_called() + + def test_status_response_uses_namespaced_request_url(self): + response: Final = httpx.Response( 200, json={"status": "IN_PROGRESS"}, request=httpx.Request( @@ -309,6 +416,83 @@ class TestFalAIVideoTransformation: with pytest.raises(ValueError, match="generation failed"): self.config.transform_video_content_response(response, self.logging_obj) + def test_content_response_surfaces_list_detail_error(self): + response: Final = httpx.Response( + 422, + json={ + "detail": [ + { + "loc": ["body", "input.reference_image_urls"], + "msg": "Failed to download the file. Please check if the URL is accessible and try again.", + } + ] + }, + request=httpx.Request("GET", "https://queue.fal.run/minimax/h3/requests/abc"), + ) + + with pytest.raises(FalAIVideoError) as error: + self.config.transform_video_content_response(response, self.logging_obj) + + assert error.value.status_code == 422 + assert "input.reference_image_urls: Failed to download the file" in error.value.message + + def test_content_response_surfaces_string_detail_error(self): + response: Final = httpx.Response( + 400, + json={"detail": "Request is still in progress"}, + request=httpx.Request("GET", "https://queue.fal.run/minimax/h3/requests/abc"), + ) + + with pytest.raises(FalAIVideoError) as error: + self.config.transform_video_content_response(response, self.logging_obj) + + assert error.value.status_code == 400 + assert error.value.message == "Request is still in progress" + + @pytest.mark.asyncio + async def test_async_content_response_surfaces_list_detail_error(self): + response: Final = httpx.Response( + 422, + json={ + "detail": [ + { + "loc": ["body", "input.reference_image_urls"], + "msg": "Failed to download the file. Please check if the URL is accessible and try again.", + } + ] + }, + request=httpx.Request("GET", "https://queue.fal.run/minimax/h3/requests/abc"), + ) + + with pytest.raises(FalAIVideoError) as error: + await self.config.async_transform_video_content_response(response, self.logging_obj) + + assert error.value.status_code == 422 + assert "input.reference_image_urls: Failed to download the file" in error.value.message + + @pytest.mark.asyncio + async def test_async_content_response_surfaces_string_detail_error(self): + response = httpx.Response( + 400, + json={"detail": "Request is still in progress"}, + request=httpx.Request("GET", "https://queue.fal.run/minimax/h3/requests/abc"), + ) + + with pytest.raises(FalAIVideoError) as error: + await self.config.async_transform_video_content_response(response, self.logging_obj) + + assert error.value.status_code == 400 + assert error.value.message == "Request is still in progress" + + def test_extract_video_url_surfaces_list_detail_error(self): + response: Final = Mock(spec=httpx.Response) + response.json.return_value = { + "detail": [{"loc": ["body", "input.reference_image_urls"], "msg": "Failed to download the file"}] + } + + with pytest.raises(ValueError, match=r"input\.reference_image_urls: Failed to download the file"): + self.config.transform_video_content_response(response, self.logging_obj) + def test_provider_config_and_error_class(self): provider_config = ProviderConfigManager.get_provider_video_config( model=MODEL, From ffa1cceb11c466659ada5d8cf19ce65debf8ca08 Mon Sep 17 00:00:00 2001 From: kerry Date: Mon, 21 Sep 2026 20:07:13 +0000 Subject: [PATCH 08/10] refactor(fal_ai): share result request derivation between status fetchers Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/llms/fal_ai/videos/transformation.py | 58 ++++++++++---------- 1 file changed, 30 insertions(+), 28 deletions(-) diff --git a/litellm/llms/fal_ai/videos/transformation.py b/litellm/llms/fal_ai/videos/transformation.py index 130e5bda70a..597b265fbf9 100644 --- a/litellm/llms/fal_ai/videos/transformation.py +++ b/litellm/llms/fal_ai/videos/transformation.py @@ -1,7 +1,7 @@ import math import sys import time -from collections.abc import Mapping, Sequence +from collections.abc import Mapping from dataclasses import dataclass from types import MappingProxyType from typing import Final, TypeAlias @@ -189,9 +189,9 @@ def _error_text(response_data: Mapping[str, object]) -> str | None: if isinstance(detail, str): return detail if isinstance(detail, list): - detail_items: Final[Sequence[Mapping[str, object]]] = TypeAdapter( - Sequence[Mapping[str, object]] - ).validate_python(tuple(item for item in detail if isinstance(item, Mapping))) + detail_items: Final[tuple[Mapping[str, object], ...]] = tuple( + item for item in detail if isinstance(item, Mapping) + ) detail_messages: Final[tuple[str, ...]] = tuple( message for item in detail_items if (message := _detail_item_text(item)) is not None ) @@ -217,6 +217,26 @@ def _response_string(response_data: Mapping[str, object], key: str, default: str return value if isinstance(value, str) else default +def _result_request( + raw_response: httpx.Response, + response_data: Mapping[str, object], +) -> tuple[str, Mapping[str, str]] | None: + if _response_string(response_data, "status", "IN_QUEUE") != "COMPLETED": + return None + result_url: Final[str] = str(raw_response.request.url).removesuffix("/status") + result_headers: Final[Mapping[str, str]] = MappingProxyType( + { + key: value + for key, value in ( + ("Authorization", raw_response.request.headers.get("Authorization")), + ("Content-Type", raw_response.request.headers.get("Content-Type")), + ) + if value is not None + } + ) + return result_url, result_headers + + def _status_video_object( response_data: Mapping[str, object], raw_response: httpx.Response, @@ -434,19 +454,10 @@ class FalAIVideoConfig(BaseVideoConfig): raw_response: httpx.Response, response_data: Mapping[str, object], ) -> str | None: - if _response_string(response_data, "status", "IN_QUEUE") != "COMPLETED": + result_request: Final[tuple[str, Mapping[str, str]] | None] = _result_request(raw_response, response_data) + if result_request is None: return None - result_url: Final[str] = str(raw_response.request.url).removesuffix("/status") - result_headers: Final[Mapping[str, str]] = MappingProxyType( - { - key: value - for key, value in ( - ("Authorization", raw_response.request.headers.get("Authorization")), - ("Content-Type", raw_response.request.headers.get("Content-Type")), - ) - if value is not None - } - ) + result_url, result_headers = result_request result_response: Final[httpx.Response] = _get_httpx_client().get( url=result_url, headers=result_headers, @@ -473,19 +484,10 @@ class FalAIVideoConfig(BaseVideoConfig): raw_response: httpx.Response, response_data: Mapping[str, object], ) -> str | None: - if _response_string(response_data, "status", "IN_QUEUE") != "COMPLETED": + result_request: Final[tuple[str, Mapping[str, str]] | None] = _result_request(raw_response, response_data) + if result_request is None: return None - result_url: Final[str] = str(raw_response.request.url).removesuffix("/status") - result_headers: Final[Mapping[str, str]] = MappingProxyType( - { - key: value - for key, value in ( - ("Authorization", raw_response.request.headers.get("Authorization")), - ("Content-Type", raw_response.request.headers.get("Content-Type")), - ) - if value is not None - } - ) + result_url, result_headers = result_request async_httpx_client: Final[AsyncHTTPHandler] = get_async_httpx_client(llm_provider=LlmProviders.FAL_AI) result_response: Final[httpx.Response] = await async_httpx_client.get( url=result_url, From eaa6936f13d8e30e45d1d8f7a9395cf98a083235 Mon Sep 17 00:00:00 2001 From: kerry Date: Mon, 21 Sep 2026 20:27:08 +0000 Subject: [PATCH 09/10] fix(fal_ai): carry fal response into content errors Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/llms/fal_ai/videos/transformation.py | 4 ++++ .../llms/fal_ai/videos/test_fal_ai_video_transformation.py | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/litellm/llms/fal_ai/videos/transformation.py b/litellm/llms/fal_ai/videos/transformation.py index 597b265fbf9..f3e189c5672 100644 --- a/litellm/llms/fal_ai/videos/transformation.py +++ b/litellm/llms/fal_ai/videos/transformation.py @@ -543,6 +543,8 @@ class FalAIVideoConfig(BaseVideoConfig): status_code=raw_response.status_code, message=error, headers=dict(raw_response.headers), # mutable-ok: exception headers require a mutable dictionary + request=raw_response.request, + response=raw_response, ) video_url: Final[str] = self._extract_video_url(_response_data(raw_response)) httpx_client: Final[HTTPHandler] = _get_httpx_client() @@ -559,6 +561,8 @@ class FalAIVideoConfig(BaseVideoConfig): status_code=raw_response.status_code, message=error, headers=dict(raw_response.headers), # mutable-ok: exception headers require a mutable dictionary + request=raw_response.request, + response=raw_response, ) video_url: Final[str] = self._extract_video_url(_response_data(raw_response)) async_httpx_client: Final[AsyncHTTPHandler] = get_async_httpx_client(llm_provider=LlmProviders.FAL_AI) diff --git a/tests/test_litellm/llms/fal_ai/videos/test_fal_ai_video_transformation.py b/tests/test_litellm/llms/fal_ai/videos/test_fal_ai_video_transformation.py index 570ff883eec..fe3ddc0516c 100644 --- a/tests/test_litellm/llms/fal_ai/videos/test_fal_ai_video_transformation.py +++ b/tests/test_litellm/llms/fal_ai/videos/test_fal_ai_video_transformation.py @@ -435,6 +435,7 @@ class TestFalAIVideoTransformation: assert error.value.status_code == 422 assert "input.reference_image_urls: Failed to download the file" in error.value.message + assert "Failed to download the file" in error.value.response.text def test_content_response_surfaces_string_detail_error(self): response: Final = httpx.Response( @@ -448,6 +449,7 @@ class TestFalAIVideoTransformation: assert error.value.status_code == 400 assert error.value.message == "Request is still in progress" + assert "Request is still in progress" in error.value.response.text @pytest.mark.asyncio async def test_async_content_response_surfaces_list_detail_error(self): @@ -469,6 +471,7 @@ class TestFalAIVideoTransformation: assert error.value.status_code == 422 assert "input.reference_image_urls: Failed to download the file" in error.value.message + assert "Failed to download the file" in error.value.response.text @pytest.mark.asyncio async def test_async_content_response_surfaces_string_detail_error(self): @@ -483,6 +486,7 @@ class TestFalAIVideoTransformation: assert error.value.status_code == 400 assert error.value.message == "Request is still in progress" + assert "Request is still in progress" in error.value.response.text def test_extract_video_url_surfaces_list_detail_error(self): response: Final = Mock(spec=httpx.Response) From 0b9035b48ff2d6864b7b47ae007fa1935e82b541 Mon Sep 17 00:00:00 2001 From: kerry Date: Mon, 21 Sep 2026 20:45:58 +0000 Subject: [PATCH 10/10] fix(fal_ai): handle transient result errors and inject clients Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/llms/fal_ai/videos/transformation.py | 34 +++++++--- .../test_fal_ai_video_transformation.py | 63 +++++++++++++------ 2 files changed, 71 insertions(+), 26 deletions(-) diff --git a/litellm/llms/fal_ai/videos/transformation.py b/litellm/llms/fal_ai/videos/transformation.py index f3e189c5672..51082a6773b 100644 --- a/litellm/llms/fal_ai/videos/transformation.py +++ b/litellm/llms/fal_ai/videos/transformation.py @@ -1,7 +1,7 @@ import math import sys import time -from collections.abc import Mapping +from collections.abc import Callable, Mapping from dataclasses import dataclass from types import MappingProxyType from typing import Final, TypeAlias @@ -212,6 +212,16 @@ def _result_error(raw_response: httpx.Response) -> str | None: return response_text or f"fal.ai returned HTTP {raw_response.status_code}" +def _terminal_result_error(raw_response: httpx.Response) -> str | None: + if raw_response.status_code == 429 or raw_response.status_code >= 500: + return None + return _result_error(raw_response) + + +def _get_fal_ai_async_httpx_client() -> AsyncHTTPHandler: + return get_async_httpx_client(llm_provider=LlmProviders.FAL_AI) + + def _response_string(response_data: Mapping[str, object], key: str, default: str = "") -> str: value: Final[object] = response_data.get(key) return value if isinstance(value, str) else default @@ -265,6 +275,15 @@ def _status_video_object( class FalAIVideoConfig(BaseVideoConfig): + def __init__( + self, + sync_client_factory: Callable[[], HTTPHandler] = _get_httpx_client, + async_client_factory: Callable[[], AsyncHTTPHandler] = _get_fal_ai_async_httpx_client, + ) -> None: + super().__init__() + self._sync_client_factory: Final = sync_client_factory + self._async_client_factory: Final = async_client_factory + def get_supported_openai_params(self, model: str) -> _SupportedParams: supported_params: Final[_SupportedParams] = [ # mutable-ok: BaseVideoConfig requires a list "model", @@ -458,11 +477,11 @@ class FalAIVideoConfig(BaseVideoConfig): if result_request is None: return None result_url, result_headers = result_request - result_response: Final[httpx.Response] = _get_httpx_client().get( + result_response: Final[httpx.Response] = self._sync_client_factory().get( url=result_url, headers=result_headers, ) - return _result_error(result_response) + return _terminal_result_error(result_response) async def async_transform_video_status_retrieve_response( self, @@ -488,12 +507,11 @@ class FalAIVideoConfig(BaseVideoConfig): if result_request is None: return None result_url, result_headers = result_request - async_httpx_client: Final[AsyncHTTPHandler] = get_async_httpx_client(llm_provider=LlmProviders.FAL_AI) - result_response: Final[httpx.Response] = await async_httpx_client.get( + result_response: Final[httpx.Response] = await self._async_client_factory().get( url=result_url, headers=result_headers, ) - return _result_error(result_response) + return _terminal_result_error(result_response) @staticmethod def _decode_video_id(video_id: str) -> tuple[str, str]: @@ -547,7 +565,7 @@ class FalAIVideoConfig(BaseVideoConfig): response=raw_response, ) video_url: Final[str] = self._extract_video_url(_response_data(raw_response)) - httpx_client: Final[HTTPHandler] = _get_httpx_client() + httpx_client: Final[HTTPHandler] = self._sync_client_factory() video_response: Final[httpx.Response] = httpx_client.get( # pyright: ignore[reportUnknownMemberType] # HTTP handler stubs are untyped video_url ) @@ -565,7 +583,7 @@ class FalAIVideoConfig(BaseVideoConfig): response=raw_response, ) video_url: Final[str] = self._extract_video_url(_response_data(raw_response)) - async_httpx_client: Final[AsyncHTTPHandler] = get_async_httpx_client(llm_provider=LlmProviders.FAL_AI) + async_httpx_client: Final[AsyncHTTPHandler] = self._async_client_factory() video_response: Final[httpx.Response] = await async_httpx_client.get( # pyright: ignore[reportUnknownMemberType] # HTTP handler stubs are untyped video_url ) diff --git a/tests/test_litellm/llms/fal_ai/videos/test_fal_ai_video_transformation.py b/tests/test_litellm/llms/fal_ai/videos/test_fal_ai_video_transformation.py index fe3ddc0516c..86ecbf6701b 100644 --- a/tests/test_litellm/llms/fal_ai/videos/test_fal_ai_video_transformation.py +++ b/tests/test_litellm/llms/fal_ai/videos/test_fal_ai_video_transformation.py @@ -216,9 +216,10 @@ class TestFalAIVideoTransformation: ({"request_id": "abc", "status": "COMPLETED"}, "completed"), ], ) - def test_status_response_mapping(self, response_data, expected_status, monkeypatch): + def test_status_response_mapping(self, response_data, expected_status): status_url = "https://queue.fal.run/bytedance/seedance-2.5/requests/abc/status" response = httpx.Response(200, json=response_data, request=httpx.Request("GET", status_url)) + config = self.config if expected_status == "completed": result_response: Final = httpx.Response( 200, @@ -227,9 +228,9 @@ class TestFalAIVideoTransformation: ) client: Final = Mock() client.get.return_value = result_response - monkeypatch.setattr(fal_video_module, "_get_httpx_client", lambda: client) + config = FalAIVideoConfig(sync_client_factory=lambda: client) - video = self.config.transform_video_status_retrieve_response( + video = config.transform_video_status_retrieve_response( raw_response=response, logging_obj=self.logging_obj, custom_llm_provider="fal_ai", @@ -249,7 +250,7 @@ class TestFalAIVideoTransformation: ) assert poll_url == status_url - def test_status_response_error(self, monkeypatch): + def test_status_response_error(self): response_data = { "request_id": "abc", "status": "COMPLETED", @@ -268,9 +269,9 @@ class TestFalAIVideoTransformation: ) client: Final = Mock() client.get.return_value = result_response - monkeypatch.setattr(fal_video_module, "_get_httpx_client", lambda: client) + config = FalAIVideoConfig(sync_client_factory=lambda: client) - video = self.config.transform_video_status_retrieve_response( + video = config.transform_video_status_retrieve_response( raw_response=response, logging_obj=self.logging_obj, custom_llm_provider="fal_ai", @@ -279,7 +280,7 @@ class TestFalAIVideoTransformation: assert video.status == "failed" assert video.error == {"code": "fal_error", "message": "generation failed"} - def test_status_completed_result_error_surfaces_fal_message(self, monkeypatch): + def test_status_completed_result_error_surfaces_fal_message(self): status_url = "https://queue.fal.run/minimax/h3/requests/abc/status" auth_headers: Final = {"Authorization": "Key synthetic-fal-key", "Content-Type": "application/json"} response: Final = httpx.Response( @@ -302,9 +303,9 @@ class TestFalAIVideoTransformation: ) client: Final = Mock() client.get.return_value = result_response - monkeypatch.setattr(fal_video_module, "_get_httpx_client", lambda: client) + config = FalAIVideoConfig(sync_client_factory=lambda: client) - video = self.config.transform_video_status_retrieve_response( + video = config.transform_video_status_retrieve_response( raw_response=response, logging_obj=self.logging_obj, custom_llm_provider="fal_ai", @@ -314,8 +315,34 @@ class TestFalAIVideoTransformation: assert "input.reference_image_urls: Failed to download the file" in video.error["message"] client.get.assert_called_once_with(url=result_url, headers=auth_headers) + @pytest.mark.parametrize("status_code", [429, 503]) + def test_status_completed_transient_result_error_keeps_completed(self, status_code): + status_url = "https://queue.fal.run/minimax/h3/requests/abc/status" + response: Final = httpx.Response( + 200, + json={"request_id": "abc", "status": "COMPLETED"}, + request=httpx.Request("GET", status_url), + ) + result_response: Final = httpx.Response( + status_code, + json={"detail": "temporary fal failure"}, + request=httpx.Request("GET", status_url.removesuffix("/status")), + ) + client: Final = Mock() + client.get.return_value = result_response + config = FalAIVideoConfig(sync_client_factory=lambda: client) + + video = config.transform_video_status_retrieve_response( + raw_response=response, + logging_obj=self.logging_obj, + custom_llm_provider="fal_ai", + ) + + assert video.status == "completed" + assert video.error is None + @pytest.mark.asyncio - async def test_async_status_completed_result_error_surfaces_fal_message(self, monkeypatch): + async def test_async_status_completed_result_error_surfaces_fal_message(self): status_url = "https://queue.fal.run/minimax/h3/requests/abc/status" auth_headers: Final = {"Authorization": "Key synthetic-fal-key", "Content-Type": "application/json"} response: Final = httpx.Response( @@ -338,9 +365,9 @@ class TestFalAIVideoTransformation: ) client: Final = Mock() client.get = AsyncMock(return_value=result_response) - monkeypatch.setattr(fal_video_module, "get_async_httpx_client", lambda llm_provider: client) + config = FalAIVideoConfig(async_client_factory=lambda: client) - video = await self.config.async_transform_video_status_retrieve_response( + video = await config.async_transform_video_status_retrieve_response( raw_response=response, logging_obj=self.logging_obj, custom_llm_provider="fal_ai", @@ -350,7 +377,7 @@ class TestFalAIVideoTransformation: assert "input.reference_image_urls: Failed to download the file" in video.error["message"] client.get.assert_awaited_once_with(url=result_url, headers=auth_headers) - def test_status_in_progress_does_not_fetch_result(self, monkeypatch): + def test_status_in_progress_does_not_fetch_result(self): status_url = "https://queue.fal.run/minimax/h3/requests/abc/status" response = httpx.Response( 200, @@ -359,9 +386,9 @@ class TestFalAIVideoTransformation: ) client: Final = Mock() - monkeypatch.setattr(fal_video_module, "_get_httpx_client", lambda: client) + config = FalAIVideoConfig(sync_client_factory=lambda: client) - video = self.config.transform_video_status_retrieve_response( + video = config.transform_video_status_retrieve_response( raw_response=response, logging_obj=self.logging_obj, custom_llm_provider="fal_ai", @@ -391,7 +418,7 @@ class TestFalAIVideoTransformation: assert decoded["video_id"] == "xyz" assert video.model == "workflows/owner/app" - def test_content_response_downloads_video_url(self, monkeypatch): + def test_content_response_downloads_video_url(self): content_response = httpx.Response( 200, content=b"video-bytes", @@ -403,11 +430,11 @@ class TestFalAIVideoTransformation: assert url == "https://cdn.example.com/video.mp4" return content_response - monkeypatch.setattr(fal_video_module, "_get_httpx_client", lambda: FakeHTTPClient()) + config = FalAIVideoConfig(sync_client_factory=FakeHTTPClient) response = Mock(spec=httpx.Response) response.json.return_value = {"video": {"url": "https://cdn.example.com/video.mp4"}} - assert self.config.transform_video_content_response(response, self.logging_obj) == b"video-bytes" + assert config.transform_video_content_response(response, self.logging_obj) == b"video-bytes" def test_content_response_rejects_missing_video(self): response = Mock(spec=httpx.Response)