From be6cb0e9247f23600e3ee9ec8fde71c4dcd64eb9 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Sat, 6 Jun 2026 20:18:33 -0700 Subject: [PATCH] refactor(ui): extract proxy base-URL and header resolution into src/lib/http Moves the stateful API-target resolution out of the networking.tsx god-module into src/lib/http/proxyBase.ts: proxyBaseUrl, serverRootPath, the worker-URL localStorage handling, getProxyBaseUrl, updateProxyBaseUrl, switchToWorkerUrl, getWindowLocation, and the global header name get/set. networking.tsx now imports what it uses and re-exports the previously-public symbols, so its ~300 importers are untouched. This flips the one backwards dependency the http layer had: src/lib/http no longer needs to reach into the component tree for the base URL, which is what was forcing the new openapi-fetch client (api.ts) to import from networking. It's a pure relocation; the singleton state is shared because one module instance is imported by both networking and the http client. Drops the dead updateServerRootPath, and replaces the isLocal console.log silencing with an inline NODE_ENV check. Adds proxyBase.test.ts covering the getProxyBaseUrl window-origin fallback, the worker-URL switch and its non-http-scheme rejection, and the header-name get/set. --- .../src/components/networking.tsx | 126 +++--------------- .../src/lib/http/proxyBase.test.ts | 52 ++++++++ .../src/lib/http/proxyBase.ts | 108 +++++++++++++++ 3 files changed, 178 insertions(+), 108 deletions(-) create mode 100644 ui/litellm-dashboard/src/lib/http/proxyBase.test.ts create mode 100644 ui/litellm-dashboard/src/lib/http/proxyBase.ts diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index d303734dffd..140736c49c9 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -42,108 +42,31 @@ import { jsonFields } from "./common_components/check_openapi_schema"; import NotificationsManager from "./molecules/notifications_manager"; import type { MCPUserEnvVarsStatus } from "./mcp_tools/types"; import { createApiClient, deriveErrorMessage } from "@/lib/http/client"; -import { resolveApiBase } from "@/lib/http/resolveApiBase"; +import { + proxyBaseUrl, + defaultProxyBaseUrl, + globalLitellmHeaderName, + getProxyBaseUrl, + getGlobalLitellmHeaderName, + getWindowLocation, + updateProxyBaseUrl, +} from "@/lib/http/proxyBase"; export { deriveErrorMessage }; export { ApiError } from "@/lib/http/client"; +export { + proxyBaseUrl, + serverRootPath, + getProxyBaseUrl, + switchToWorkerUrl, + setGlobalLitellmHeaderName, + getGlobalLitellmHeaderName, +} from "@/lib/http/proxyBase"; -const isLocal = process.env.NODE_ENV === "development"; -// In dev, if NEXT_PUBLIC_USE_REWRITES=true the Next.js dev server proxies API calls -// to the backend — use relative URLs (null) so rewrites can intercept them. -const resolveDefaultBase = (fallback: string | null): string | null => - process.env.NEXT_PUBLIC_BASE_URL - ? process.env.NEXT_PUBLIC_BASE_URL - : isLocal && process.env.NEXT_PUBLIC_USE_REWRITES !== "true" - ? "http://localhost:4000" - : fallback; -const defaultProxyBaseUrl = resolveDefaultBase(null); -const defaultServerRootPath = "/"; -export let serverRootPath = defaultServerRootPath; -const WORKER_URL_KEY = "litellm_worker_url"; -// If a worker URL is in localStorage, use it as the initial proxyBaseUrl. -// This survives page navigation and the sessionStorage.clear() in user_dashboard. -const _rawWorkerUrl = typeof window !== "undefined" ? window.localStorage.getItem(WORKER_URL_KEY) : null; -// Validate stored worker URL — reject non-HTTP schemes to prevent exfiltration -const _initialWorkerUrl = (() => { - if (!_rawWorkerUrl) return null; - try { - const parsed = new URL(_rawWorkerUrl); - if (parsed.protocol === "http:" || parsed.protocol === "https:") return _rawWorkerUrl; - } catch { - /* invalid URL */ - } - // Invalid URL in storage — clear it - if (typeof window !== "undefined") window.localStorage.removeItem(WORKER_URL_KEY); - return null; -})(); -export let proxyBaseUrl: string | null = _initialWorkerUrl ?? defaultProxyBaseUrl; -if (isLocal != true) { +if (process.env.NODE_ENV !== "development") { console.log = function () {}; } -const getWindowLocation = () => { - if (typeof window === "undefined") { - return null; - } - return window.location; -}; - -const updateProxyBaseUrl = (serverRootPath: string, receivedProxyBaseUrl: string | null = null) => { - /** - * Special function for updating the proxy base url. Should only be called by getUiConfig. - */ - // If a worker URL is in localStorage, don't let getUiConfig overwrite it - if (typeof window !== "undefined" && window.localStorage.getItem(WORKER_URL_KEY)) { - return; - } - proxyBaseUrl = resolveApiBase({ - explicitBase: receivedProxyBaseUrl || resolveDefaultBase(getWindowLocation()?.origin ?? null), - serverRootPath, - }); -}; - -const updateServerRootPath = (receivedServerRootPath: string) => { - serverRootPath = receivedServerRootPath; -}; - -export const getProxyBaseUrl = (): string => { - if (proxyBaseUrl) { - return proxyBaseUrl; - } - const browserLocation = getWindowLocation(); - return browserLocation?.origin ?? ""; -}; - -/** - * Switch API calls to point at a worker (or back to the control plane). - * Persists to localStorage so it survives page navigation and the - * sessionStorage.clear() in user_dashboard. Also updates the module-level - * proxyBaseUrl so in-flight code in this JS execution sees the new value - * immediately. - */ -function isValidHttpUrl(url: string): boolean { - try { - const parsed = new URL(url); - return parsed.protocol === "http:" || parsed.protocol === "https:"; - } catch { - return false; - } -} - -export function switchToWorkerUrl(workerUrl: string | null): void { - if (workerUrl && !isValidHttpUrl(workerUrl)) { - return; - } - if (typeof window !== "undefined") { - if (workerUrl) { - window.localStorage.setItem(WORKER_URL_KEY, workerUrl); - } else { - window.localStorage.removeItem(WORKER_URL_KEY); - } - } - proxyBaseUrl = workerUrl ?? defaultProxyBaseUrl; -} - const HTTP_REQUEST = { GET: "GET", POST: "POST", @@ -356,21 +279,8 @@ export const getAgentCreateMetadata = async (): Promise => { return jsonData; }; -// Global variable for the header name -let globalLitellmHeaderName: string = "Authorization"; const MCP_AUTH_HEADER: string = "x-mcp-auth"; -// Function to set the global header name -export function setGlobalLitellmHeaderName(headerName: string = "Authorization") { - console.log(`setGlobalLitellmHeaderName: ${headerName}`); - globalLitellmHeaderName = headerName; -} - -// Function to get the global header name -export function getGlobalLitellmHeaderName(): string { - return globalLitellmHeaderName; -} - const apiClient = createApiClient({ getBaseUrl: getProxyBaseUrl, getAuthHeaderName: getGlobalLitellmHeaderName, diff --git a/ui/litellm-dashboard/src/lib/http/proxyBase.test.ts b/ui/litellm-dashboard/src/lib/http/proxyBase.test.ts new file mode 100644 index 00000000000..d6b72da35c7 --- /dev/null +++ b/ui/litellm-dashboard/src/lib/http/proxyBase.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + getProxyBaseUrl, + switchToWorkerUrl, + getGlobalLitellmHeaderName, + setGlobalLitellmHeaderName, +} from "./proxyBase"; + +const WORKER_URL_KEY = "litellm_worker_url"; + +describe("proxyBase", () => { + beforeEach(() => { + window.localStorage.clear(); + switchToWorkerUrl(null); + setGlobalLitellmHeaderName("Authorization"); + }); + + describe("getProxyBaseUrl", () => { + it("falls back to the window origin when no base is configured", () => { + expect(getProxyBaseUrl()).toBe(window.location.origin); + }); + }); + + describe("switchToWorkerUrl", () => { + it("points the base at a valid worker URL and persists it", () => { + switchToWorkerUrl("https://worker.example.com"); + expect(getProxyBaseUrl()).toBe("https://worker.example.com"); + expect(window.localStorage.getItem(WORKER_URL_KEY)).toBe("https://worker.example.com"); + }); + + it("rejects a non-http(s) scheme and leaves the base unchanged", () => { + switchToWorkerUrl("javascript:alert(1)"); + expect(getProxyBaseUrl()).toBe(window.location.origin); + expect(window.localStorage.getItem(WORKER_URL_KEY)).toBeNull(); + }); + + it("clears the worker URL and falls back when passed null", () => { + switchToWorkerUrl("https://worker.example.com"); + switchToWorkerUrl(null); + expect(getProxyBaseUrl()).toBe(window.location.origin); + expect(window.localStorage.getItem(WORKER_URL_KEY)).toBeNull(); + }); + }); + + describe("global header name", () => { + it("defaults to Authorization and reflects updates", () => { + expect(getGlobalLitellmHeaderName()).toBe("Authorization"); + setGlobalLitellmHeaderName("x-litellm-api-key"); + expect(getGlobalLitellmHeaderName()).toBe("x-litellm-api-key"); + }); + }); +}); diff --git a/ui/litellm-dashboard/src/lib/http/proxyBase.ts b/ui/litellm-dashboard/src/lib/http/proxyBase.ts new file mode 100644 index 00000000000..c1edcb99164 --- /dev/null +++ b/ui/litellm-dashboard/src/lib/http/proxyBase.ts @@ -0,0 +1,108 @@ +import { resolveApiBase } from "./resolveApiBase"; + +const isLocal = process.env.NODE_ENV === "development"; +// In dev, if NEXT_PUBLIC_USE_REWRITES=true the Next.js dev server proxies API calls +// to the backend — use relative URLs (null) so rewrites can intercept them. +const resolveDefaultBase = (fallback: string | null): string | null => + process.env.NEXT_PUBLIC_BASE_URL + ? process.env.NEXT_PUBLIC_BASE_URL + : isLocal && process.env.NEXT_PUBLIC_USE_REWRITES !== "true" + ? "http://localhost:4000" + : fallback; + +export const defaultProxyBaseUrl = resolveDefaultBase(null); +const defaultServerRootPath = "/"; +export let serverRootPath = defaultServerRootPath; + +const WORKER_URL_KEY = "litellm_worker_url"; +// If a worker URL is in localStorage, use it as the initial proxyBaseUrl. +// This survives page navigation and the sessionStorage.clear() in user_dashboard. +const _rawWorkerUrl = typeof window !== "undefined" ? window.localStorage.getItem(WORKER_URL_KEY) : null; +// Validate stored worker URL — reject non-HTTP schemes to prevent exfiltration +const _initialWorkerUrl = (() => { + if (!_rawWorkerUrl) return null; + try { + const parsed = new URL(_rawWorkerUrl); + if (parsed.protocol === "http:" || parsed.protocol === "https:") return _rawWorkerUrl; + } catch { + /* invalid URL */ + } + // Invalid URL in storage — clear it + if (typeof window !== "undefined") window.localStorage.removeItem(WORKER_URL_KEY); + return null; +})(); + +export let proxyBaseUrl: string | null = _initialWorkerUrl ?? defaultProxyBaseUrl; + +export const getWindowLocation = () => { + if (typeof window === "undefined") { + return null; + } + return window.location; +}; + +export const updateProxyBaseUrl = (serverRootPath: string, receivedProxyBaseUrl: string | null = null) => { + /** + * Special function for updating the proxy base url. Should only be called by getUiConfig. + */ + // If a worker URL is in localStorage, don't let getUiConfig overwrite it + if (typeof window !== "undefined" && window.localStorage.getItem(WORKER_URL_KEY)) { + return; + } + proxyBaseUrl = resolveApiBase({ + explicitBase: receivedProxyBaseUrl || resolveDefaultBase(getWindowLocation()?.origin ?? null), + serverRootPath, + }); +}; + +export const getProxyBaseUrl = (): string => { + if (proxyBaseUrl) { + return proxyBaseUrl; + } + const browserLocation = getWindowLocation(); + return browserLocation?.origin ?? ""; +}; + +/** + * Switch API calls to point at a worker (or back to the control plane). + * Persists to localStorage so it survives page navigation and the + * sessionStorage.clear() in user_dashboard. Also updates the module-level + * proxyBaseUrl so in-flight code in this JS execution sees the new value + * immediately. + */ +function isValidHttpUrl(url: string): boolean { + try { + const parsed = new URL(url); + return parsed.protocol === "http:" || parsed.protocol === "https:"; + } catch { + return false; + } +} + +export function switchToWorkerUrl(workerUrl: string | null): void { + if (workerUrl && !isValidHttpUrl(workerUrl)) { + return; + } + if (typeof window !== "undefined") { + if (workerUrl) { + window.localStorage.setItem(WORKER_URL_KEY, workerUrl); + } else { + window.localStorage.removeItem(WORKER_URL_KEY); + } + } + proxyBaseUrl = workerUrl ?? defaultProxyBaseUrl; +} + +// Global variable for the header name +export let globalLitellmHeaderName: string = "Authorization"; + +// Function to set the global header name +export function setGlobalLitellmHeaderName(headerName: string = "Authorization") { + console.log(`setGlobalLitellmHeaderName: ${headerName}`); + globalLitellmHeaderName = headerName; +} + +// Function to get the global header name +export function getGlobalLitellmHeaderName(): string { + return globalLitellmHeaderName; +}