From dfca463df2d6bc416fb9b92d629b2eedb7f6eaef Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 27 Jun 2026 23:48:23 +0000 Subject: [PATCH] fix(dashboard): guard gen:api against unsupported Python and prefer .venv The gen:api script defaulted to a bare python3, which on macOS is often the system interpreter (3.9). Run against the repo source, that interpreter dies deep in litellm's import chain on dataclass(slots=True) with a cryptic TypeError that points at completion.py, not at the real cause. Resolve the repo's .venv interpreter before falling back to python3, and probe the chosen interpreter's version up front so an unsupported Python fails with an actionable message naming the interpreter, its version, and how to fix it, instead of a confusing slots traceback. LITELLM_PYTHON still wins so CI is unchanged. --- .../scripts/gen-api-types.mjs | 120 +++++++++++++----- .../tests/gen-api-types.test.ts | 69 ++++++++++ 2 files changed, 154 insertions(+), 35 deletions(-) create mode 100644 ui/litellm-dashboard/tests/gen-api-types.test.ts 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"); + }); +});