diff --git a/ui/litellm-dashboard/src/components/networking.test.ts b/ui/litellm-dashboard/src/components/networking.test.ts index c358859ea3f..430f297dabc 100644 --- a/ui/litellm-dashboard/src/components/networking.test.ts +++ b/ui/litellm-dashboard/src/components/networking.test.ts @@ -147,6 +147,295 @@ describe("handleErrorResponse - status-aware auth handling", () => { }); }); +describe("handleErrorResponse - type-based auth routing (D1 contract)", () => { + // These tests cover the NEW path that reads `error.type` from the + // response body. When the backend emits a specific auth_* type from + // ProxyErrorTypes, the UI should dispatch by table lookup — no + // regex, no cookie-presence guess. The status code is informational + // only; the type is the source of truth. + let originalLocation: Location; + + beforeEach(() => { + vi.clearAllMocks(); + originalLocation = window.location; + delete (window as any).location; + (window as any).location = { ...originalLocation, href: "/admin", pathname: "/admin" }; + }); + + afterEach(() => { + (window as any).location = originalLocation; + }); + + // --- REDIRECT_LOGIN types ------------------------------------------------ + + it("redirects on type=auth_session_expired regardless of cookie/status", async () => { + // Even with a still-present cookie, the structured type + // unambiguously says "session is gone". Redirect, no question. + vi.mocked(getCookie).mockReturnValue("might-still-be-cached" as any); + + await Networking.handleErrorResponse( + { status: 401 }, + { error: { type: "auth_session_expired", message: "Key has expired" } }, + ); + + expect(clearTokenCookies).toHaveBeenCalledOnce(); + }); + + it("redirects on type=auth_invalid_credentials", async () => { + vi.mocked(getCookie).mockReturnValue("anything" as any); + + await Networking.handleErrorResponse( + { status: 401 }, + { error: { type: "auth_invalid_credentials", message: "No auth header" } }, + ); + + expect(clearTokenCookies).toHaveBeenCalledOnce(); + }); + + it("redirects on legacy type=expired_key (predates D1)", async () => { + // Backward compat — existing code paths that already raised + // ProxyException with type=expired_key continue to work. + vi.mocked(getCookie).mockReturnValue("anything" as any); + + await Networking.handleErrorResponse( + { status: 401 }, + { error: { type: "expired_key", message: "Key has expired" } }, + ); + + expect(clearTokenCookies).toHaveBeenCalledOnce(); + }); + + it("redirects on legacy type=token_not_found_in_db", async () => { + vi.mocked(getCookie).mockReturnValue("anything" as any); + + await Networking.handleErrorResponse( + { status: 401 }, + { error: { type: "token_not_found_in_db", message: "..." } }, + ); + + expect(clearTokenCookies).toHaveBeenCalledOnce(); + }); + + // --- TOAST types --------------------------------------------------------- + + it("does NOT redirect on type=auth_permission_denied (the bug we're fixing)", async () => { + // This is the case the whole D1+D2 effort exists for: the user is + // logged in, they just called an endpoint their role can't reach. + // No redirect, no cookie clearing, just a toast. + vi.mocked(getCookie).mockReturnValue("valid-token" as any); + + await Networking.handleErrorResponse( + { status: 401 }, + { error: { type: "auth_permission_denied", message: "Master Key required" } }, + ); + + expect(clearTokenCookies).not.toHaveBeenCalled(); + }); + + it("does NOT redirect on type=key_model_access_denied", async () => { + vi.mocked(getCookie).mockReturnValue("valid-token" as any); + + await Networking.handleErrorResponse( + { status: 401 }, + { error: { type: "key_model_access_denied", message: "Key does not have access to gpt-4" } }, + ); + + expect(clearTokenCookies).not.toHaveBeenCalled(); + }); + + it("does NOT redirect on type=team_member_permission_error", async () => { + vi.mocked(getCookie).mockReturnValue("valid-token" as any); + + await Networking.handleErrorResponse( + { status: 403 }, + { error: { type: "team_member_permission_error", message: "..." } }, + ); + + expect(clearTokenCookies).not.toHaveBeenCalled(); + }); + + it("does NOT redirect on type=budget_exceeded", async () => { + // Budget exhaustion is permission-like: caller is who they say + // they are, just out of credit. Toast, don't bounce. + vi.mocked(getCookie).mockReturnValue("valid-token" as any); + + await Networking.handleErrorResponse( + { status: 400 }, + { error: { type: "budget_exceeded", message: "Budget exceeded" } }, + ); + + expect(clearTokenCookies).not.toHaveBeenCalled(); + }); + + // --- HEURISTIC fallback -------------------------------------------------- + + it("falls through to heuristic on type=auth_error (generic) — cookie present", async () => { + // Generic type means backend couldn't classify. Use the cookie + // heuristic. Cookie present + no marker -> don't redirect (the + // step-2 safe default). + vi.mocked(getCookie).mockReturnValue("valid-token" as any); + + await Networking.handleErrorResponse( + { status: 401 }, + { error: { type: "auth_error", message: "something obscure" } }, + ); + + expect(clearTokenCookies).not.toHaveBeenCalled(); + }); + + it("falls through to heuristic on type=auth_error — cookie absent → redirect", async () => { + vi.mocked(getCookie).mockReturnValue(undefined as any); + + await Networking.handleErrorResponse( + { status: 401 }, + { error: { type: "auth_error", message: "something obscure" } }, + ); + + expect(clearTokenCookies).toHaveBeenCalledOnce(); + }); + + it("falls through to heuristic on unknown type", async () => { + // Unknown / future / typo'd type — heuristic still runs, no crash. + vi.mocked(getCookie).mockReturnValue("valid-token" as any); + + await Networking.handleErrorResponse( + { status: 401 }, + { error: { type: "some_brand_new_type_we_dont_know", message: "..." } }, + ); + + expect(clearTokenCookies).not.toHaveBeenCalled(); + }); + + // --- Body-shape robustness ----------------------------------------------- + + it("reads type from top-level field (admin-endpoint shape)", async () => { + // Some admin endpoints respond with {type, message} at the top + // level rather than {error: {type, message}}. extractErrorType + // handles both. + vi.mocked(getCookie).mockReturnValue("anything" as any); + + await Networking.handleErrorResponse( + { status: 401 }, + { type: "auth_session_expired", message: "..." }, + ); + + expect(clearTokenCookies).toHaveBeenCalledOnce(); + }); + + it("falls through to heuristic when body is a string (no structured type)", async () => { + // Legacy backend behavior — body is just a string. extractErrorType + // returns null, the heuristic runs. + vi.mocked(getCookie).mockReturnValue(undefined as any); + + await Networking.handleErrorResponse({ status: 401 }, "Authentication Error - Expired Key"); + + expect(clearTokenCookies).toHaveBeenCalledOnce(); + }); + + it("falls through to heuristic when body has no error.type field", async () => { + vi.mocked(getCookie).mockReturnValue("valid-token" as any); + + // Cookie present + no type + no marker → no redirect (safe default). + await Networking.handleErrorResponse( + { status: 401 }, + { error: { message: "some message without a type field" } }, + ); + + expect(clearTokenCookies).not.toHaveBeenCalled(); + }); +}); + +describe("handleError (legacy) - now also reads error.type", () => { + // The 30+ existing callers use handleError(errorData) — they don't + // pass a Response object. To make D1's structured types actually + // affect production, the legacy handler itself must read error.type. + // This describe covers that "make it work for callers we didn't + // migrate" path. + // + // handleError throttles itself with a 60s rate limit (lastErrorTime + // module-level), so back-to-back tests would be suppressed. Use + // fake timers + advance system time well past 60s between each test + // so every test sees a clean rate-limit window. + + // Use a fake clock anchored once for the whole describe so each test + // sees a monotonically increasing time and the rate-limit window + // always clears between calls. afterEach -> useRealTimers would reset + // the clock back to OS time and recreate the throttle window. + const baseTime = new Date("2030-01-01T00:00:00Z").getTime(); + let testCounter = 0; + + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + testCounter += 1; + // Each test runs at base + N hours; rate-limit window (60s) is + // dwarfed by the per-test 1h gap. + vi.setSystemTime(new Date(baseTime + testCounter * 3600 * 1000)); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("redirects when error.type=auth_session_expired", async () => { + await Networking.handleError({ + error: { type: "auth_session_expired", message: "Key has expired" }, + }); + expect(clearTokenCookies).toHaveBeenCalledOnce(); + }); + + it("redirects when error.type=auth_invalid_credentials (D1's new type)", async () => { + await Networking.handleError({ + error: { type: "auth_invalid_credentials", message: "No api key passed in." }, + }); + expect(clearTokenCookies).toHaveBeenCalledOnce(); + }); + + it("redirects when error.type=token_not_found_in_db (existing legacy specific type)", async () => { + await Networking.handleError({ + error: { type: "token_not_found_in_db", message: "Invalid proxy server token..." }, + }); + expect(clearTokenCookies).toHaveBeenCalledOnce(); + }); + + it("does NOT redirect when error.type=auth_permission_denied (the bug)", async () => { + // legacy handleError previously had no way to know this was a + // permission case, so it just did nothing (correct outcome). With + // the type-aware path, we still do nothing for permission types — + // no false-positive redirect. + await Networking.handleError({ + error: { type: "auth_permission_denied", message: "Master Key required" }, + }); + expect(clearTokenCookies).not.toHaveBeenCalled(); + }); + + it("does NOT redirect when error.type=budget_exceeded", async () => { + await Networking.handleError({ + error: { type: "budget_exceeded", message: "Budget exceeded" }, + }); + expect(clearTokenCookies).not.toHaveBeenCalled(); + }); + + it("falls back to marker heuristic when no type but message has marker", async () => { + // Older backend without D1 — body is just a string with a marker. + await Networking.handleError("Authentication Error - Expired Key"); + expect(clearTokenCookies).toHaveBeenCalledOnce(); + }); + + it("does nothing when no type, no marker (legacy behavior preserved)", async () => { + await Networking.handleError("Some unrelated error message"); + expect(clearTokenCookies).not.toHaveBeenCalled(); + }); + + it("type=auth_error falls through to marker heuristic", async () => { + // Backend gave up on classifying; marker present in message. + await Networking.handleError({ + error: { type: "auth_error", message: "Authentication Error - Expired Key" }, + }); + expect(clearTokenCookies).toHaveBeenCalledOnce(); + }); +}); + describe("loginCall - storeLoginToken integration", () => { const originalFetch = global.fetch; diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index a46e9b0f1ca..66c885f00c7 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -341,12 +341,66 @@ export interface CredentialsResponse { let lastErrorTime = 0; +// PRIMARY signal — backend's structured `error.type` field. See +// litellm.proxy._types.ProxyErrorTypes. The proxy classifies every wrapped +// auth failure into one of these strings; the UI just dispatches the right +// action by table lookup, no regex needed. +// +// Adding a new auth type? Mirror the docstring on the backend enum entry +// here so the contract is visible from both sides. +type AuthAction = "REDIRECT_LOGIN" | "TOAST" | "HEURISTIC"; + +const AUTH_ERROR_TYPE_TO_ACTION: Record = { + // Session is gone — local auth state is no good. Clear + redirect. + auth_session_expired: "REDIRECT_LOGIN", + auth_invalid_credentials: "REDIRECT_LOGIN", + expired_key: "REDIRECT_LOGIN", // legacy specific type, predates D1 + token_not_found_in_db: "REDIRECT_LOGIN", // ditto + + // Caller authenticated but lacks privilege for THIS endpoint. Toast + // only — bouncing them to login would be hostile UX. + auth_permission_denied: "TOAST", + key_model_access_denied: "TOAST", + team_model_access_denied: "TOAST", + user_model_access_denied: "TOAST", + org_model_access_denied: "TOAST", + project_model_access_denied: "TOAST", + key_vector_store_access_denied: "TOAST", + team_member_permission_error: "TOAST", + + // Budget exhaustion is a permission-like state — the user IS who they + // say they are; their budget just ran out. Toast. + budget_exceeded: "TOAST", + + // Generic auth_error means "backend couldn't classify" — fall back to + // the cookie + marker heuristic. Same for unknown types via the lookup + // miss path below. + auth_error: "HEURISTIC", +}; + +/** + * Extract the structured `type` field from a backend error body, if + * present. The proxy wraps responses two ways depending on the endpoint: + * { error: { type: "...", message: "...", code: "401" } } ← chat/completions + * { type: "...", message: "..." } ← admin endpoints + * + * We accept either shape and return null when nothing recognizable is + * there (legacy backend before D1, or non-auth error path). + */ +const extractErrorType = (errorData: any): string | null => { + if (!errorData || typeof errorData === "string") return null; + const nested = errorData?.error?.type; + const flat = errorData?.type; + const value = typeof nested === "string" ? nested : typeof flat === "string" ? flat : null; + return value && value.length > 0 ? value : null; +}; + // Substrings the backend uses across the various ways an auth credential // becomes invalid (expired, revoked, no token, malformed). Matching ANY of // these means "your session is gone — go log in again". String matching is // fragile by nature (backend wording can drift), so we treat these as a -// best-effort fallback. The primary signal — when the caller can supply it -// — is HTTP status + the cookie presence check, see `handleErrorResponse`. +// best-effort fallback used ONLY when the structured `type` field is +// missing or maps to "HEURISTIC" (i.e. generic `auth_error`). const SESSION_EXPIRED_SIGNALS = [ "Authentication Error - Expired Key", "Authentication Error - Invalid", @@ -375,61 +429,109 @@ const triggerSessionExpiredRedirect = () => { }; /** - * Status-aware error handler — preferred over `handleError` whenever the - * caller has the original `Response` object. Distinguishes "your session is - * gone, go log in" (401 with no cookie OR a session-expired marker in the - * body) from "this endpoint requires more privilege" (403, or 401 while - * the cookie is still present and the body shape says permission-only). + * Status-aware error handler. Three-tier decision: + * + * 1. PREFERRED: structured `error.type` from the response body. The + * backend (since the D1 taxonomy patch) emits one of: + * auth_session_expired / auth_invalid_credentials → REDIRECT_LOGIN + * auth_permission_denied / *_access_denied / *_permission_error → TOAST + * budget_exceeded → TOAST + * auth_error → HEURISTIC (fall through to step 2) + * Type lookup is exact, cannot be wrong, and survives any backend + * message wording drift. + * + * 2. FALLBACK heuristic (when type is missing or HEURISTIC): use HTTP + * status + cookie presence + message-marker scan. This is the path + * used if the backend is on an older build that hasn't been + * upgraded to include the structured types yet, so the upgrade is + * safely incremental. * - * - 401 + session-expired signal -> clear cookies + redirect to login - * - 403 -> toast only, never redirect (you're logged in, just not allowed) - * - other errors -> delegate to `handleError` (same rate-limited path) + * 3. Non-auth errors (4xx that aren't 401/403, 5xx, etc) delegate to + * the legacy `handleError` (same rate-limited path; no redirect). + * + * Caller responsibility: pass the original `Response` object (or at + * least a `{status: number}`) so step 1/2 can dispatch correctly. */ export const handleErrorResponse = async ( response: Pick | { status: number }, errorData: string | any, ): Promise => { const status = response?.status; + + // --- Step 1: try the structured type first -------------------------------- + const errorType = extractErrorType(errorData); + if (errorType) { + const action = AUTH_ERROR_TYPE_TO_ACTION[errorType]; + if (action === "REDIRECT_LOGIN") { + triggerSessionExpiredRedirect(); + return; + } + if (action === "TOAST") { + NotificationsManager.fromBackend(errorData); + return; + } + // action === "HEURISTIC" or undefined (unknown type) → fall through + // to step 2. Don't return here — let status-based heuristic decide. + } + + // --- Step 2: status-based heuristic (legacy / unknown type fallback) ------ const errorString = typeof errorData === "string" ? errorData : JSON.stringify(errorData ?? ""); if (status === 401) { - // The most reliable "session is gone" signal: the cookie is no longer - // present at all. If a session ever existed it has been cleared - // already, so push the user back to login. - const hasAuthCookie = - typeof window !== "undefined" && Boolean(getCookie("token") || getCookie("session_token")); + // No cookie = session is definitely gone (no race between server + // and client). If there IS a cookie, look for a message marker + // (less reliable, but covers older backends without the type field). + const hasAuthCookie = typeof window !== "undefined" && Boolean(getCookie("token")); if (!hasAuthCookie || isSessionExpiredFromMessage(errorString)) { triggerSessionExpiredRedirect(); return; } - // 401 but the cookie is still present — likely "your role can't call - // THIS specific endpoint" expressed as 401 (LiteLLM uses 401 for both - // session-gone and role-mismatch, sigh). Show a toast, don't bounce - // the user out of a perfectly fine session. + // 401 + cookie + no expiry marker → treat as permission-denied to + // avoid bouncing a still-valid session out for what is probably a + // role mismatch. NotificationsManager.fromBackend(errorData); return; } if (status === 403) { - // Forbidden = "I know who you are, but you can't do this". Never log - // the user out for a 403; just surface what the backend said. + // Forbidden — authenticated but not authorized. Never redirect. NotificationsManager.fromBackend(errorData); return; } - // Non-auth errors fall through to the existing rate-limited handler. + // --- Step 3: non-auth errors → legacy rate-limited handler --------------- await handleError(errorData); }; export const handleError = async (errorData: string | any) => { const currentTime = Date.now(); if (currentTime - lastErrorTime > 60000) { - // 60000 milliseconds = 60 seconds - // Convert errorData to string if it isn't already + // 60000 milliseconds = 60 seconds. + + // STEP 1: prefer the structured `error.type` from the backend (D1 + // contract). This is the path that actually fires for the ~30 + // legacy callers that didn't migrate to handleErrorResponse — by + // making the legacy entry point smart, every fetch site + // automatically benefits from the D1 backend taxonomy without + // touching their call sites. + const errorType = extractErrorType(errorData); + if (errorType) { + const action = AUTH_ERROR_TYPE_TO_ACTION[errorType]; + if (action === "REDIRECT_LOGIN") { + lastErrorTime = currentTime; + triggerSessionExpiredRedirect(); + return; + } + // action === "TOAST" or "HEURISTIC" or unknown — fall through to + // the legacy marker path. We deliberately do NOT toast here: + // handleError is the rate-limited error funnel and not every + // caller wants its own toast; preserving the legacy behavior of + // "do nothing for non-session-gone" keeps backward compatibility. + } + + // STEP 2: legacy marker-based fallback for older backend builds + // and `auth_error` (generic, no type signal) cases. const errorString = typeof errorData === "string" ? errorData : JSON.stringify(errorData); - // Match any known session-expired marker, not just the historical - // "Expired Key" wording — the backend emits several variants and the - // single-string match used to miss most of them. if (isSessionExpiredFromMessage(errorString)) { lastErrorTime = currentTime; triggerSessionExpiredRedirect();