diff --git a/ui/litellm-dashboard/scripts/gen-api-types.mjs b/ui/litellm-dashboard/scripts/gen-api-types.mjs index 3c9373ec547..dc988d9297f 100644 --- a/ui/litellm-dashboard/scripts/gen-api-types.mjs +++ b/ui/litellm-dashboard/scripts/gen-api-types.mjs @@ -7,46 +7,96 @@ * the spec is read straight off the app object, so this runs in CI without a * database or proxy boot. * - * The Python interpreter must have litellm installed. Override which one via - * LITELLM_PYTHON (CI passes "uv run --no-sync python"); defaults to python3. + * The Python interpreter must have litellm installed and be at least the + * version litellm requires. Override which one via LITELLM_PYTHON (CI passes + * "uv run --no-sync python"); otherwise the repo's .venv is preferred, falling + * back to python3. */ import { execFileSync } from "node:child_process"; -import { mkdtempSync, rmSync } from "node:fs"; +import { existsSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -const dashboardDir = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const repoRoot = resolve(dashboardDir, "..", ".."); -const outPath = join(dashboardDir, "src", "lib", "http", "schema.d.ts"); -const specDir = mkdtempSync(join(tmpdir(), "litellm-openapi-")); -const specPath = join(specDir, "openapi.json"); +const MIN_PYTHON = { major: 3, minor: 10 }; -const python = (process.env.LITELLM_PYTHON ?? "python3").split(" "); -// The dashboard calls internal UI routes that the public /openapi.json hides via -// include_in_schema=False. Force them in so they get typed here; this mutates a -// throwaway interpreter, so the spec the proxy actually serves is unchanged. -const dumpSpec = [ - "import json, sys", - "from litellm.proxy.proxy_server import app", - "from fastapi.routing import APIRoute", - "for route in app.routes:", - " if isinstance(route, APIRoute):", - " route.include_in_schema = True", - "app.openapi_schema = None", - "with open(sys.argv[1], 'w') as f: json.dump(app.openapi(), f, sort_keys=True)", -].join("\n"); - -try { - execFileSync(python[0], [...python.slice(1), "-c", dumpSpec, specPath], { - cwd: repoRoot, - stdio: "inherit", - }); - - execFileSync(join(dashboardDir, "node_modules", ".bin", "openapi-typescript"), [specPath, "-o", outPath], { - cwd: dashboardDir, - stdio: "inherit", - }); -} finally { - rmSync(specDir, { recursive: true, force: true }); +export function resolvePythonCommand(env, repoRoot, exists = existsSync) { + if (env.LITELLM_PYTHON) return env.LITELLM_PYTHON.split(" ").filter(Boolean); + const venvCandidates = [join(repoRoot, ".venv", "bin", "python"), join(repoRoot, ".venv", "Scripts", "python.exe")]; + const venvPython = venvCandidates.find((candidate) => exists(candidate)); + return venvPython ? [venvPython] : ["python3"]; } + +export function parsePythonVersion(stdout) { + const match = stdout.match(/(\d+)\.(\d+)/); + return match ? { major: Number(match[1]), minor: Number(match[2]) } : null; +} + +export function isSupportedPython(version) { + if (!version) return false; + return version.major > MIN_PYTHON.major || (version.major === MIN_PYTHON.major && version.minor >= MIN_PYTHON.minor); +} + +export function unsupportedPythonMessage(pythonCommand, version) { + const interpreter = pythonCommand.join(" "); + const detected = version ? `${version.major}.${version.minor}` : "unknown"; + return ( + `litellm requires Python >=${MIN_PYTHON.major}.${MIN_PYTHON.minor}, ` + + `but \`${interpreter}\` reports ${detected}. ` + + "Point gen:api at a supported interpreter (e.g. `uv venv && uv sync`, or set " + + "LITELLM_PYTHON to one with litellm installed), then re-run `npm run gen:api`." + ); +} + +function assertSupportedPython(pythonCommand) { + const probe = "import sys; print('.'.join(map(str, sys.version_info[:2])))"; + const stdout = execFileSync(pythonCommand[0], [...pythonCommand.slice(1), "-c", probe], { + encoding: "utf8", + }); + const version = parsePythonVersion(stdout); + if (!isSupportedPython(version)) { + throw new Error(unsupportedPythonMessage(pythonCommand, version)); + } +} + +function main() { + const dashboardDir = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + const repoRoot = resolve(dashboardDir, "..", ".."); + const outPath = join(dashboardDir, "src", "lib", "http", "schema.d.ts"); + const specDir = mkdtempSync(join(tmpdir(), "litellm-openapi-")); + const specPath = join(specDir, "openapi.json"); + + const python = resolvePythonCommand(process.env, repoRoot); + // The dashboard calls internal UI routes that the public /openapi.json hides via + // include_in_schema=False. Force them in so they get typed here; this mutates a + // throwaway interpreter, so the spec the proxy actually serves is unchanged. + const dumpSpec = [ + "import json, sys", + "from litellm.proxy.proxy_server import app", + "from fastapi.routing import APIRoute", + "for route in app.routes:", + " if isinstance(route, APIRoute):", + " route.include_in_schema = True", + "app.openapi_schema = None", + "with open(sys.argv[1], 'w') as f: json.dump(app.openapi(), f, sort_keys=True)", + ].join("\n"); + + try { + assertSupportedPython(python); + + execFileSync(python[0], [...python.slice(1), "-c", dumpSpec, specPath], { + cwd: repoRoot, + stdio: "inherit", + }); + + execFileSync(join(dashboardDir, "node_modules", ".bin", "openapi-typescript"), [specPath, "-o", outPath], { + cwd: dashboardDir, + stdio: "inherit", + }); + } finally { + rmSync(specDir, { recursive: true, force: true }); + } +} + +const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (isMain) main(); diff --git a/ui/litellm-dashboard/tests/gen-api-types.test.ts b/ui/litellm-dashboard/tests/gen-api-types.test.ts new file mode 100644 index 00000000000..80c396930d4 --- /dev/null +++ b/ui/litellm-dashboard/tests/gen-api-types.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect } from "vitest"; +import { + resolvePythonCommand, + parsePythonVersion, + isSupportedPython, + unsupportedPythonMessage, +} from "../scripts/gen-api-types.mjs"; + +const repoRoot = "/repo"; + +describe("resolvePythonCommand", () => { + it("honors LITELLM_PYTHON, splitting multi-word commands", () => { + const cmd = resolvePythonCommand({ LITELLM_PYTHON: "uv run --no-sync python" }, repoRoot, () => false); + expect(cmd).toEqual(["uv", "run", "--no-sync", "python"]); + }); + + it("prefers the repo's .venv interpreter over a bare python3", () => { + const venv = `${repoRoot}/.venv/bin/python`; + const cmd = resolvePythonCommand({}, repoRoot, (p) => p === venv); + expect(cmd).toEqual([venv]); + }); + + it("finds the Windows .venv interpreter", () => { + const venv = `${repoRoot}/.venv/Scripts/python.exe`; + const cmd = resolvePythonCommand({}, repoRoot, (p) => p === venv); + expect(cmd).toEqual([venv]); + }); + + it("falls back to python3 when no venv exists", () => { + expect(resolvePythonCommand({}, repoRoot, () => false)).toEqual(["python3"]); + }); +}); + +describe("parsePythonVersion", () => { + it("parses major.minor from the probe output", () => { + expect(parsePythonVersion("3.9\n")).toEqual({ major: 3, minor: 9 }); + expect(parsePythonVersion("3.13\n")).toEqual({ major: 3, minor: 13 }); + }); + + it("returns null on unparseable output", () => { + expect(parsePythonVersion("not a version")).toBeNull(); + }); +}); + +describe("isSupportedPython", () => { + it("rejects the 3.9 interpreter that breaks on dataclass slots=True", () => { + expect(isSupportedPython({ major: 3, minor: 9 })).toBe(false); + }); + + it("accepts 3.10 and newer", () => { + expect(isSupportedPython({ major: 3, minor: 10 })).toBe(true); + expect(isSupportedPython({ major: 3, minor: 13 })).toBe(true); + expect(isSupportedPython({ major: 4, minor: 0 })).toBe(true); + }); + + it("rejects an unknown version", () => { + expect(isSupportedPython(null)).toBe(false); + }); +}); + +describe("unsupportedPythonMessage", () => { + it("names the interpreter, the detected version, and how to fix it", () => { + const message = unsupportedPythonMessage(["python3"], { major: 3, minor: 9 }); + expect(message).toContain("python3"); + expect(message).toContain("3.9"); + expect(message).toContain("LITELLM_PYTHON"); + expect(message).toContain(">=3.10"); + }); +});