diff --git a/ui/litellm-dashboard/src/components/claude_code_plugins/helpers.test.ts b/ui/litellm-dashboard/src/components/claude_code_plugins/helpers.test.ts
index b3930d15718..4c84db2a97d 100644
--- a/ui/litellm-dashboard/src/components/claude_code_plugins/helpers.test.ts
+++ b/ui/litellm-dashboard/src/components/claude_code_plugins/helpers.test.ts
@@ -15,23 +15,34 @@ import {
isValidUrl,
parseKeywords,
formatKeywords,
+ parseSkillSource,
+ isValidSubPath,
} from "./helpers";
import { MarketplacePluginEntry, PluginSource } from "./types";
describe("formatInstallCommand", () => {
it("formats github source with repo", () => {
- const plugin = { name: "my-plugin", source: { source: "github" as const, repo: "org/repo" } };
- expect(formatInstallCommand(plugin)).toBe("/plugin marketplace add org/repo");
+ const source: PluginSource = { source: "github", repo: "org/repo" };
+ expect(formatInstallCommand({ name: "my-plugin", source })).toBe("/plugin marketplace add org/repo");
});
it("formats url source", () => {
- const plugin = { name: "my-plugin", source: { source: "url" as const, url: "https://example.com/plugin" } };
- expect(formatInstallCommand(plugin)).toBe("/plugin marketplace add https://example.com/plugin");
+ const source: PluginSource = { source: "url", url: "https://example.com/plugin" };
+ expect(formatInstallCommand({ name: "my-plugin", source })).toBe(
+ "/plugin marketplace add https://example.com/plugin",
+ );
+ });
+
+ it("formats git-subdir source using its url", () => {
+ const source: PluginSource = { source: "git-subdir", url: "https://github.com/org/repo", path: "plugins/x" };
+ expect(formatInstallCommand({ name: "my-plugin", source })).toBe(
+ "/plugin marketplace add https://github.com/org/repo",
+ );
});
it("falls back to plugin name when no repo or url", () => {
- const plugin = { name: "my-plugin", source: { source: "github" as const } };
- expect(formatInstallCommand(plugin)).toBe("/plugin marketplace add my-plugin");
+ const source: PluginSource = { source: "github" };
+ expect(formatInstallCommand({ name: "my-plugin", source })).toBe("/plugin marketplace add my-plugin");
});
});
@@ -91,6 +102,18 @@ describe("getSourceDisplayText", () => {
expect(getSourceDisplayText({ source: "url", url: "https://example.com" })).toBe("https://example.com");
});
+ it("shows git-subdir as url @ path for a github subdir", () => {
+ expect(getSourceDisplayText({ source: "git-subdir", url: "https://github.com/org/repo", path: "plugins/x" })).toBe(
+ "https://github.com/org/repo @ plugins/x",
+ );
+ });
+
+ it("shows git-subdir as url @ path for a gitlab subdir", () => {
+ expect(getSourceDisplayText({ source: "git-subdir", url: "https://gitlab.com/org/repo", path: "sub/dir" })).toBe(
+ "https://gitlab.com/org/repo @ sub/dir",
+ );
+ });
+
it("returns unknown for missing data", () => {
expect(getSourceDisplayText({ source: "github" })).toBe("Unknown source");
});
@@ -105,6 +128,18 @@ describe("getSourceLink", () => {
expect(getSourceLink({ source: "url", url: "https://example.com" })).toBe("https://example.com");
});
+ it("returns the repo url for a github git-subdir source", () => {
+ expect(getSourceLink({ source: "git-subdir", url: "https://github.com/org/repo", path: "plugins/x" })).toBe(
+ "https://github.com/org/repo",
+ );
+ });
+
+ it("returns the repo url for a gitlab git-subdir source", () => {
+ expect(getSourceLink({ source: "git-subdir", url: "https://gitlab.com/org/repo", path: "sub/dir" })).toBe(
+ "https://gitlab.com/org/repo",
+ );
+ });
+
it("returns null when no repo or url", () => {
expect(getSourceLink({ source: "github" })).toBeNull();
});
@@ -323,3 +358,216 @@ describe("formatKeywords", () => {
expect(formatKeywords(undefined)).toBe("");
});
});
+
+describe("parseSkillSource", () => {
+ it("parses a plain github repo", () => {
+ expect(parseSkillSource("github.com/org/repo")?.parsed).toEqual({ source: "github", repo: "org/repo" });
+ });
+
+ it("strips a .git suffix from the github repo shorthand", () => {
+ expect(parseSkillSource("https://github.com/org/repo.git")?.parsed).toEqual({
+ source: "github",
+ repo: "org/repo",
+ });
+ });
+
+ it("parses a github tree URL into a git-subdir", () => {
+ expect(parseSkillSource("github.com/org/repo/tree/main/plugins/x")?.parsed).toEqual({
+ source: "git-subdir",
+ url: "https://github.com/org/repo",
+ path: "plugins/x",
+ });
+ });
+
+ it("drops a trailing file segment from a github blob URL", () => {
+ expect(parseSkillSource("github.com/org/repo/blob/main/x/SKILL.md")?.parsed).toEqual({
+ source: "git-subdir",
+ url: "https://github.com/org/repo",
+ path: "x",
+ });
+ });
+
+ it("combines a github repo with an explicit subfolder", () => {
+ expect(parseSkillSource("github.com/org/repo", "plugins/x")?.parsed).toEqual({
+ source: "git-subdir",
+ url: "https://github.com/org/repo",
+ path: "plugins/x",
+ });
+ });
+
+ it("treats a gitlab repo as a raw url source", () => {
+ expect(parseSkillSource("gitlab.com/org/repo")?.parsed).toEqual({
+ source: "url",
+ url: "https://gitlab.com/org/repo",
+ });
+ });
+
+ it("keeps the .git suffix on raw urls", () => {
+ expect(parseSkillSource("https://gitlab.com/org/repo.git")?.parsed).toEqual({
+ source: "url",
+ url: "https://gitlab.com/org/repo.git",
+ });
+ });
+
+ it("combines a gitlab repo with an explicit subfolder", () => {
+ expect(parseSkillSource("gitlab.com/org/repo", "plugins/x")?.parsed).toEqual({
+ source: "git-subdir",
+ url: "https://gitlab.com/org/repo",
+ path: "plugins/x",
+ });
+ });
+
+ it("combines a self-hosted host with an explicit subfolder", () => {
+ expect(parseSkillSource("https://git.acme.com/team/repo", "sub/dir")?.parsed).toEqual({
+ source: "git-subdir",
+ url: "https://git.acme.com/team/repo",
+ path: "sub/dir",
+ });
+ });
+
+ it("lets a github URL-encoded subdir win over an also-provided subfolder", () => {
+ expect(parseSkillSource("github.com/org/repo/tree/main/plugins/x", "ignored/path")?.parsed).toEqual({
+ source: "git-subdir",
+ url: "https://github.com/org/repo",
+ path: "plugins/x",
+ });
+ });
+
+ it("rejects traversal, absolute, and double-slash subfolders", () => {
+ expect(parseSkillSource("gitlab.com/org/repo", "../etc")).toBeNull();
+ expect(parseSkillSource("gitlab.com/org/repo", "/abs")).toBeNull();
+ expect(parseSkillSource("gitlab.com/org/repo", "a//b")).toBeNull();
+ });
+
+ it("returns null for empty and garbage input", () => {
+ expect(parseSkillSource("")).toBeNull();
+ expect(parseSkillSource(" ")).toBeNull();
+ expect(parseSkillSource("not a url")).toBeNull();
+ });
+
+ it("suggests a kebab-friendly name from the last path segment", () => {
+ expect(parseSkillSource("github.com/org/my-awesome-skill")?.suggestedName).toBe("my-awesome-skill");
+ expect(parseSkillSource("github.com/org/repo/tree/main/plugins/cool-skill")?.suggestedName).toBe("cool-skill");
+ expect(parseSkillSource("gitlab.com/org/repo", "plugins/x")?.suggestedName).toBe("x");
+ });
+
+ it("rejects a bad explicit subfolder for a github repo", () => {
+ expect(parseSkillSource("github.com/org/repo", "../etc")).toBeNull();
+ expect(parseSkillSource("github.com/org/repo", "/abs")).toBeNull();
+ expect(parseSkillSource("github.com/org/repo", "a//b")).toBeNull();
+ });
+
+ it("treats a blob URL pointing at a root file as the plain repo", () => {
+ expect(parseSkillSource("github.com/org/repo/blob/main/SKILL.md")?.parsed).toEqual({
+ source: "github",
+ repo: "org/repo",
+ });
+ });
+
+ it("strips query strings and fragments before parsing", () => {
+ expect(parseSkillSource("github.com/org/repo?tab=readme")?.parsed).toEqual({ source: "github", repo: "org/repo" });
+ expect(parseSkillSource("github.com/org/repo#section")?.parsed).toEqual({ source: "github", repo: "org/repo" });
+ });
+
+ it("rejects a tree URL whose folder has a space or percent-encoded segment", () => {
+ expect(parseSkillSource("github.com/org/repo/tree/main/a b")).toBeNull();
+ expect(parseSkillSource("github.com/org/repo/tree/main/a%20b")).toBeNull();
+ });
+
+ it("routes uppercase and www github hosts through the github shorthand", () => {
+ expect(parseSkillSource("GitHub.com/org/repo/tree/main/x")?.parsed).toEqual({
+ source: "git-subdir",
+ url: "https://github.com/org/repo",
+ path: "x",
+ });
+ expect(parseSkillSource("www.github.com/org/repo")?.parsed).toEqual({ source: "github", repo: "org/repo" });
+ });
+
+ it("keeps a dotted folder name as the subdir path", () => {
+ expect(parseSkillSource("github.com/org/repo/blob/main/my.skill")?.parsed).toEqual({
+ source: "git-subdir",
+ url: "https://github.com/org/repo",
+ path: "my.skill",
+ });
+ });
+
+ it("falls back to the repo for a tree URL with a branch but no folder", () => {
+ expect(parseSkillSource("github.com/org/repo/tree/main")?.parsed).toEqual({ source: "github", repo: "org/repo" });
+ });
+
+ it("kebab-cases the suggested name from a mixed-case repo", () => {
+ expect(parseSkillSource("github.com/Org/My_Repo")?.suggestedName).toBe("my-repo");
+ });
+
+ it("rejects a bare host or single-segment raw git url", () => {
+ expect(parseSkillSource("gitlab.com")).toBeNull();
+ expect(parseSkillSource("gitlab.com/org")).toBeNull();
+ });
+});
+
+// Skill sources are served on the unauthenticated public feeds and cloned by clients, so the
+// parser must never publish an insecure, credentialed, internal, or malformed clone URL.
+describe("parseSkillSource — security boundary", () => {
+ it("rejects non-https schemes", () => {
+ for (const url of [
+ "http://gitlab.com/org/repo",
+ "HTTP://gitlab.com/org/repo",
+ "ssh://gitlab.com/org/repo",
+ "git://gitlab.com/org/repo",
+ "ftp://gitlab.com/org/repo",
+ "file:///etc/passwd",
+ "javascript:alert(1)",
+ "data:text/plain,hi",
+ "//gitlab.com/org/repo",
+ ]) {
+ expect(parseSkillSource(url)).toBeNull();
+ }
+ });
+
+ it("rejects URLs with embedded credentials", () => {
+ expect(parseSkillSource("https://user:token@gitlab.com/org/repo")).toBeNull();
+ expect(parseSkillSource("https://user@gitlab.com/org/repo")).toBeNull();
+ // userinfo confusion: the real host is evil.com, not github.com
+ expect(parseSkillSource("https://github.com@evil.com/org/repo")).toBeNull();
+ });
+
+ it("rejects IP-literal hosts (loopback, private, metadata, obfuscated, IPv6)", () => {
+ for (const url of [
+ "https://127.0.0.1/org/repo",
+ "https://10.0.0.5/org/repo",
+ "https://169.254.169.254/org/repo",
+ "https://2130706433/org/repo",
+ "https://[::ffff:127.0.0.1]/org/repo",
+ ]) {
+ expect(parseSkillSource(url)).toBeNull();
+ }
+ });
+
+ it("does not grant GitHub shorthand to a look-alike host", () => {
+ expect(parseSkillSource("https://github.com.evil.com/org/repo")?.parsed).toEqual({
+ source: "url",
+ url: "https://github.com.evil.com/org/repo",
+ });
+ });
+
+ it("rejects GitHub org/repo segments with illegal characters", () => {
+ expect(parseSkillSource("github.com/o@x/repo")).toBeNull();
+ expect(parseSkillSource("github.com/org/..%2f..%2fx")).toBeNull();
+ });
+});
+
+describe("isValidSubPath", () => {
+ it("accepts relative segment paths", () => {
+ expect(isValidSubPath("plugins/x")).toBe(true);
+ expect(isValidSubPath("sub/dir")).toBe(true);
+ expect(isValidSubPath("a.b-c_d")).toBe(true);
+ expect(isValidSubPath("plugins/x/")).toBe(true);
+ });
+
+ it("rejects empty, traversal, absolute, and double-slash paths", () => {
+ expect(isValidSubPath("")).toBe(false);
+ expect(isValidSubPath("../etc")).toBe(false);
+ expect(isValidSubPath("/abs")).toBe(false);
+ expect(isValidSubPath("a//b")).toBe(false);
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/claude_code_plugins/helpers.ts b/ui/litellm-dashboard/src/components/claude_code_plugins/helpers.ts
index d696a78b4cc..cab3c5cba3c 100644
--- a/ui/litellm-dashboard/src/components/claude_code_plugins/helpers.ts
+++ b/ui/litellm-dashboard/src/components/claude_code_plugins/helpers.ts
@@ -4,15 +4,189 @@
import { PluginSource, MarketplacePluginEntry } from "./types";
+export interface SkillSourcePreview {
+ parsed: PluginSource;
+ label: string;
+ suggestedName: string;
+}
+
+export const SUBDIR_PATH_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9._-]*(\/[a-zA-Z0-9][a-zA-Z0-9._-]*)*$/;
+
+export const normalizeSubPath = (subPath: string): string => subPath.trim().replace(/\/+$/, "");
+
+export const isValidSubPath = (subPath: string): boolean => {
+ const normalized = normalizeSubPath(subPath);
+ return normalized !== "" && SUBDIR_PATH_REGEX.test(normalized);
+};
+
+const GITHUB_HOST = "github.com";
+
+const SKILL_FILE_EXTENSION_REGEX = /\.(md|markdown|txt|json|ya?ml|toml)$/i;
+
+// WHATWG normalizes obfuscated IPv4 (e.g. 2130706433, 0x7f.0.0.1) to dotted-decimal, so this
+// catches every IPv4 form; bracketed IPv6 is rejected separately.
+const IPV4_HOST_REGEX = /^\d{1,3}(\.\d{1,3}){3}$/;
+
+const GITHUB_ORG_REGEX = /^[A-Za-z0-9-]+$/;
+const GITHUB_REPO_REGEX = /^[A-Za-z0-9._-]+$/;
+
+const buildRepoUrl = (url: URL): string => `${url.protocol}//${url.host}${url.pathname.replace(/\/+$/, "")}`;
+
+const pathSegments = (url: URL): string[] => url.pathname.split("/").filter((seg) => seg !== "");
+
+/**
+ * Validate and normalize a repository URL into a parsed URL, or null. Enforces https (rejects
+ * http/ssh/git/etc.), rejects embedded credentials, and requires a dotted host, so the public
+ * skill feeds never serve an insecure or credentialed clone URL. Everything downstream parses
+ * this normalized object rather than the raw string.
+ */
+const parseRepoUrl = (raw: string): URL | null => {
+ const trimmed = raw.trim();
+ if (trimmed === "" || trimmed.startsWith("//")) {
+ return null;
+ }
+ const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
+ let url: URL;
+ try {
+ url = new URL(withScheme);
+ } catch {
+ return null;
+ }
+ if (
+ url.protocol !== "https:" ||
+ url.username !== "" ||
+ url.password !== "" ||
+ !url.hostname.includes(".") ||
+ url.hostname.startsWith("[") ||
+ IPV4_HOST_REGEX.test(url.hostname)
+ ) {
+ return null;
+ }
+ return url;
+};
+
+const lastSegment = (path: string): string => {
+ const segments = path.split("/").filter((seg) => seg !== "");
+ return segments[segments.length - 1] ?? "";
+};
+
+const toKebabCase = (value: string): string =>
+ value
+ .toLowerCase()
+ .replace(/[^a-z0-9-]+/g, "-")
+ .replace(/-+/g, "-")
+ .replace(/^-+|-+$/g, "");
+
+const parseGitHubSource = (url: URL, subPath?: string): SkillSourcePreview | null => {
+ const parts = pathSegments(url);
+ if (parts.length < 2) {
+ return null;
+ }
+
+ const org = parts[0];
+ const repoBase = parts[1].replace(/\.git$/, "");
+ if (!GITHUB_ORG_REGEX.test(org) || !GITHUB_REPO_REGEX.test(repoBase)) {
+ return null;
+ }
+ const repoFull = `${org}/${repoBase}`;
+ const repoUrl = `https://github.com/${repoFull}`;
+ const repoPreview: SkillSourcePreview = {
+ parsed: { source: "github", repo: repoFull },
+ label: `GitHub repo — ${repoFull}`,
+ suggestedName: toKebabCase(repoBase),
+ };
+
+ const isTreeOrBlob = parts.length >= 4 && (parts[2] === "tree" || parts[2] === "blob");
+ if (isTreeOrBlob) {
+ const pathParts = parts.slice(4);
+ const last = lastSegment(pathParts.join("/"));
+ const effective = SKILL_FILE_EXTENSION_REGEX.test(last) ? pathParts.slice(0, -1) : pathParts;
+ if (effective.length === 0) {
+ return repoPreview;
+ }
+ const path = normalizeSubPath(effective.join("/"));
+ if (!SUBDIR_PATH_REGEX.test(path)) {
+ return null;
+ }
+ return {
+ parsed: { source: "git-subdir", url: repoUrl, path },
+ label: `GitHub subdir — ${repoFull} @ ${path}`,
+ suggestedName: toKebabCase(lastSegment(path)),
+ };
+ }
+
+ if (parts.length !== 2) {
+ return null;
+ }
+
+ const normalized = normalizeSubPath(subPath ?? "");
+ if (normalized !== "") {
+ if (!SUBDIR_PATH_REGEX.test(normalized)) {
+ return null;
+ }
+ return {
+ parsed: { source: "git-subdir", url: repoUrl, path: normalized },
+ label: `GitHub subdir — ${repoFull} @ ${normalized}`,
+ suggestedName: toKebabCase(lastSegment(normalized)),
+ };
+ }
+
+ return repoPreview;
+};
+
+const parseRawGitSource = (url: URL, subPath?: string): SkillSourcePreview | null => {
+ if (pathSegments(url).length < 2) {
+ return null;
+ }
+
+ const repoUrl = buildRepoUrl(url);
+
+ const normalized = normalizeSubPath(subPath ?? "");
+ if (normalized !== "") {
+ if (!SUBDIR_PATH_REGEX.test(normalized)) {
+ return null;
+ }
+ return {
+ parsed: { source: "git-subdir", url: repoUrl, path: normalized },
+ label: `Git subdir — ${repoUrl} @ ${normalized}`,
+ suggestedName: toKebabCase(lastSegment(normalized)),
+ };
+ }
+
+ return {
+ parsed: { source: "url", url: repoUrl },
+ label: `Git repo — ${repoUrl}`,
+ suggestedName: toKebabCase(lastSegment(url.pathname).replace(/\.git$/, "")),
+ };
+};
+
+/**
+ * Parse any git-accessible repository URL into a registerable skill source.
+ * GitHub URLs keep their `github`/`git-subdir` shorthand; every other host is
+ * treated as a raw repo URL, with an optional subfolder turning it into git-subdir.
+ */
+export const parseSkillSource = (rawUrl: string, subPath?: string): SkillSourcePreview | null => {
+ const url = parseRepoUrl(rawUrl);
+ if (!url) {
+ return null;
+ }
+ if (url.hostname.replace(/^www\./, "") === GITHUB_HOST) {
+ return parseGitHubSource(url, subPath);
+ }
+ return parseRawGitSource(url, subPath);
+};
+
/**
* Generate install command for Claude Code CLI
* Format: /plugin marketplace add org/repo OR /plugin marketplace add url
*/
export const formatInstallCommand = (plugin: { name: string; source: PluginSource }): string => {
- if (plugin.source.source === "github" && plugin.source.repo) {
- return `/plugin marketplace add ${plugin.source.repo}`;
- } else if (plugin.source.source === "url" && plugin.source.url) {
- return `/plugin marketplace add ${plugin.source.url}`;
+ const { source } = plugin;
+ if (source.source === "github" && source.repo) {
+ return `/plugin marketplace add ${source.repo}`;
+ }
+ if ((source.source === "url" || source.source === "git-subdir") && source.url) {
+ return `/plugin marketplace add ${source.url}`;
}
// Fallback to plugin name
return `/plugin marketplace add ${plugin.name}`;
@@ -55,7 +229,11 @@ export const validatePluginName = (name: string): boolean => {
export const getSourceDisplayText = (source: PluginSource): string => {
if (source.source === "github" && source.repo) {
return `GitHub: ${source.repo}`;
- } else if (source.source === "url" && source.url) {
+ }
+ if (source.source === "git-subdir" && source.url && source.path) {
+ return `${source.url} @ ${source.path}`;
+ }
+ if (source.source === "url" && source.url) {
return source.url;
}
return "Unknown source";
@@ -67,7 +245,8 @@ export const getSourceDisplayText = (source: PluginSource): string => {
export const getSourceLink = (source: PluginSource): string | null => {
if (source.source === "github" && source.repo) {
return `https://github.com/${source.repo}`;
- } else if (source.source === "url" && source.url) {
+ }
+ if ((source.source === "url" || source.source === "git-subdir") && source.url) {
return source.url;
}
return null;
diff --git a/ui/litellm-dashboard/src/components/claude_code_plugins/types.ts b/ui/litellm-dashboard/src/components/claude_code_plugins/types.ts
index fcb1146685d..d16c880749b 100644
--- a/ui/litellm-dashboard/src/components/claude_code_plugins/types.ts
+++ b/ui/litellm-dashboard/src/components/claude_code_plugins/types.ts
@@ -1,8 +1,12 @@
/**
* TypeScript types for Claude Code Marketplace
- * Matches backend API types from /litellm/types/proxy/claude_code_endpoints.py
+ * API request/response shapes are synced from the generated OpenAPI types in @/lib/http/schema.
*/
+import type { components } from "@/lib/http/schema";
+
+// Kept hand-written: the backend types `source` as Dict[str, str], so the generated type is a
+// loose string map; this discriminant union is what the parser and display helpers rely on.
export interface PluginSource {
source: "github" | "url" | "git-subdir";
repo?: string; // Format: "org/repo" for GitHub
@@ -10,10 +14,7 @@ export interface PluginSource {
path?: string; // Subdirectory path for git-subdir
}
-export interface PluginAuthor {
- name: string;
- email?: string;
-}
+export type PluginAuthor = components["schemas"]["PluginAuthor"];
export interface Plugin {
id: string;
@@ -56,24 +57,12 @@ export interface ListPluginsResponse {
count: number;
}
-export interface RegisterPluginRequest {
- name: string;
+// Request envelope synced from the OpenAPI spec, with `source` narrowed to our PluginSource
+// union and `version` kept optional (the backend supplies its default).
+export type SkillRegisterRequest = Omit
& {
source: PluginSource;
version?: string;
- description?: string;
- author?: PluginAuthor;
- homepage?: string;
- keywords?: string[];
- category?: string;
- domain?: string;
- namespace?: string;
-}
-
-export interface RegisterPluginResponse {
- plugin: Plugin;
- action: "created" | "updated";
- message: string;
-}
+};
// Public marketplace types
export interface MarketplacePluginEntry {
@@ -104,20 +93,3 @@ export interface CategoryTab {
label: string;
count: number;
}
-
-export interface PluginFormData {
- name: string;
- sourceType: "github" | "url" | "git-subdir";
- repo: string;
- url: string;
- path: string;
- version: string;
- description: string;
- authorName: string;
- authorEmail: string;
- homepage: string;
- category: string;
- keywords: string; // Comma-separated string, will be split into array
- domain: string;
- namespace: string;
-}
diff --git a/ui/litellm-dashboard/src/components/key_team_helpers/filter_logic.test.tsx b/ui/litellm-dashboard/src/components/key_team_helpers/filter_logic.test.tsx
deleted file mode 100644
index 23259528687..00000000000
--- a/ui/litellm-dashboard/src/components/key_team_helpers/filter_logic.test.tsx
+++ /dev/null
@@ -1,181 +0,0 @@
-import { act, renderHook, waitFor } from "@testing-library/react";
-import { beforeEach, describe, expect, it, vi } from "vitest";
-import { useFilterLogic } from "./filter_logic";
-import { keyListCall } from "../networking";
-
-vi.mock("../networking", () => ({
- keyListCall: vi.fn(),
-}));
-
-vi.mock("./filter_helpers", () => ({
- fetchAllTeams: vi.fn().mockResolvedValue([]),
- fetchAllOrganizations: vi.fn().mockResolvedValue([]),
-}));
-
-const mockKey = {
- token: "abc123",
- key_alias: "aaaaa",
- team_id: null,
- organization_id: null,
-};
-
-const defaultProps = {
- keys: [mockKey] as any[],
- teams: [],
- organizations: [],
-};
-
-const makeApiResponse = (overrides: { keys?: any[]; total_count?: number; total_pages?: number } = {}) => ({
- keys: overrides.keys ?? [mockKey],
- total_count: overrides.total_count ?? 1,
- current_page: 1,
- total_pages: overrides.total_pages ?? 1,
-});
-
-describe("useFilterLogic – filteredTotalCount", () => {
- beforeEach(() => {
- vi.clearAllMocks();
- vi.mocked(keyListCall).mockResolvedValue(makeApiResponse({ total_count: 509, total_pages: 11 }));
- });
-
- it("should expose filteredTotalCount as null before any filter search runs", () => {
- const { result } = renderHook(() => useFilterLogic(defaultProps));
-
- expect(result.current.filteredTotalCount).toBeNull();
- });
-
- it("should set filteredTotalCount to the API total_count after a Key Alias filter is applied", async () => {
- vi.mocked(keyListCall).mockResolvedValue(makeApiResponse({ keys: [mockKey], total_count: 1, total_pages: 1 }));
-
- const { result } = renderHook(() => useFilterLogic(defaultProps));
-
- act(() => {
- result.current.handleFilterChange({ "Key Alias": "aaaaa" });
- });
-
- await waitFor(
- () => {
- expect(result.current.filteredTotalCount).toBe(1);
- },
- { timeout: 500 },
- );
- });
-
- it("should reflect the filtered total_count even when it differs from the full key count", async () => {
- vi.mocked(keyListCall).mockResolvedValue(makeApiResponse({ total_count: 7, total_pages: 1 }));
-
- const { result } = renderHook(() => useFilterLogic(defaultProps));
-
- act(() => {
- result.current.handleFilterChange({ "Team ID": "team-x" });
- });
-
- await waitFor(
- () => {
- expect(result.current.filteredTotalCount).toBe(7);
- },
- { timeout: 500 },
- );
- });
-
- it("should reset filteredTotalCount to null when handleFilterReset is called", async () => {
- vi.mocked(keyListCall).mockResolvedValue(makeApiResponse({ total_count: 1 }));
-
- const { result } = renderHook(() => useFilterLogic(defaultProps));
-
- act(() => {
- result.current.handleFilterChange({ "Key Alias": "aaaaa" });
- });
-
- await waitFor(
- () => {
- expect(result.current.filteredTotalCount).toBe(1);
- },
- { timeout: 500 },
- );
-
- act(() => {
- result.current.handleFilterReset();
- });
-
- // filteredTotalCount resets synchronously before the debounced reset search completes
- expect(result.current.filteredTotalCount).toBeNull();
- });
-
- it("should pass the Key Alias value to keyListCall", async () => {
- vi.mocked(keyListCall).mockResolvedValue(makeApiResponse({ total_count: 2 }));
-
- const { result } = renderHook(() => useFilterLogic(defaultProps));
-
- act(() => {
- result.current.handleFilterChange({ "Key Alias": "my-alias" });
- });
-
- await waitFor(
- () => {
- expect(keyListCall).toHaveBeenCalledWith(
- expect.any(String), // accessToken
- null, // organizationID (empty → null)
- null, // teamID (empty → null)
- "my-alias", // selectedKeyAlias ← the filter value
- null, // userID
- null, // keyHash
- 1, // page (resets to 1 on filter change)
- expect.any(Number), // pageSize (defaultPageSize)
- expect.anything(), // sortBy
- expect.anything(), // sortOrder
- );
- },
- { timeout: 500 },
- );
- });
-
- it("should not update filteredTotalCount when keyListCall throws", async () => {
- vi.mocked(keyListCall).mockRejectedValue(new Error("Network error"));
-
- const { result } = renderHook(() => useFilterLogic(defaultProps));
-
- act(() => {
- result.current.handleFilterChange({ "Key Alias": "bad-alias" });
- });
-
- await waitFor(
- () => {
- expect(keyListCall).toHaveBeenCalled();
- },
- { timeout: 500 },
- );
-
- expect(result.current.filteredTotalCount).toBeNull();
- });
-
- it("should not enter an infinite update loop when keys is a fresh array reference on every render", () => {
- const sourceKeys = [mockKey];
- let renderCount = 0;
-
- const { result } = renderHook(() => {
- renderCount += 1;
- const value = useFilterLogic({ keys: [...sourceKeys], teams: [], organizations: [] });
- if (renderCount > 25) {
- throw new Error(`useFilterLogic re-rendered ${renderCount} times; setFilteredKeys is looping`);
- }
- return value;
- });
-
- expect(result.current.filteredKeys).toEqual([mockKey]);
- expect(renderCount).toBeLessThanOrEqual(25);
- });
-
- it("should not trigger a debounced search when skipDebounce is true", async () => {
- const { result } = renderHook(() => useFilterLogic(defaultProps));
-
- act(() => {
- result.current.handleFilterChange({ "Sort By": "spend", "Sort Order": "asc" }, true);
- });
-
- await new Promise((resolve) => setTimeout(resolve, 350));
-
- expect(keyListCall).not.toHaveBeenCalled();
- expect(result.current.filteredTotalCount).toBeNull();
- });
-});
diff --git a/ui/litellm-dashboard/src/components/key_team_helpers/filter_logic.tsx b/ui/litellm-dashboard/src/components/key_team_helpers/filter_logic.tsx
deleted file mode 100644
index e31a6fbee38..00000000000
--- a/ui/litellm-dashboard/src/components/key_team_helpers/filter_logic.tsx
+++ /dev/null
@@ -1,188 +0,0 @@
-import { useCallback, useEffect, useState, useRef } from "react";
-import { KeyResponse } from "../key_team_helpers/key_list";
-import { keyListCall, Organization } from "../networking";
-import { Team } from "../key_team_helpers/key_list";
-import { fetchAllOrganizations, fetchAllTeams } from "./filter_helpers";
-import { debounce } from "lodash";
-import { defaultPageSize } from "../constants";
-import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
-
-export interface FilterState {
- "Team ID": string;
- "Organization ID": string;
- "Key Alias": string;
- [key: string]: string;
- "User ID": string;
- "Sort By": string;
- "Sort Order": string;
-}
-
-export function useFilterLogic({
- keys,
- teams,
- organizations,
-}: {
- keys: KeyResponse[];
- teams: Team[] | null;
- organizations: Organization[] | null;
-}) {
- const defaultFilters: FilterState = {
- "Team ID": "",
- "Organization ID": "",
- "Key Alias": "",
- "User ID": "",
- "Sort By": "created_at",
- "Sort Order": "desc",
- };
- const { accessToken } = useAuthorized();
- const [filters, setFilters] = useState(defaultFilters);
- const [allTeams, setAllTeams] = useState(teams || []);
- const [allOrganizations, setAllOrganizations] = useState(organizations || []);
- const [filteredKeys, setFilteredKeys] = useState(keys);
- const [filteredTotalCount, setFilteredTotalCount] = useState(null);
- const lastSearchTimestamp = useRef(0);
- const debouncedSearch = useCallback(
- debounce(async (filters: FilterState) => {
- if (!accessToken) {
- return;
- }
-
- const currentTimestamp = Date.now();
- lastSearchTimestamp.current = currentTimestamp;
-
- try {
- // Make the API call using userListCall with all filter parameters
- const data = await keyListCall(
- accessToken,
- filters["Organization ID"] || null,
- filters["Team ID"] || null,
- filters["Key Alias"] || null,
- filters["User ID"] || null,
- filters["Key Hash"] || null,
- 1, // Reset to first page when searching
- defaultPageSize,
- filters["Sort By"] || null,
- filters["Sort Order"] || null,
- );
-
- // Only update state if this is the most recent search
- if (currentTimestamp === lastSearchTimestamp.current) {
- if (data) {
- setFilteredKeys(data.keys);
- setFilteredTotalCount(data.total_count ?? null);
- console.log("called from debouncedSearch filters:", JSON.stringify(filters));
- console.log("called from debouncedSearch data:", JSON.stringify(data));
- }
- }
- } catch (error) {
- console.error("Error searching users:", error);
- }
- }, 300),
- [accessToken],
- );
- // Apply filters to keys whenever keys or filters change
- useEffect(() => {
- if (!keys) {
- setFilteredKeys([]);
- return;
- }
-
- let result = [...keys];
-
- // Apply Team ID filter
- if (filters["Team ID"]) {
- result = result.filter((key) => key.team_id === filters["Team ID"]);
- }
-
- // Apply Organization ID filter
- if (filters["Organization ID"]) {
- result = result.filter((key) => (key.organization_id ?? key.org_id) === filters["Organization ID"]);
- }
-
- setFilteredKeys((prev) =>
- prev.length === result.length && prev.every((key, index) => key === result[index]) ? prev : result,
- );
- }, [keys, filters]);
-
- // Fetch all data for filters when component mounts
- useEffect(() => {
- const loadAllFilterData = async () => {
- // Load all teams - no organization filter needed here
- const teamsData = await fetchAllTeams(accessToken);
- if (teamsData.length > 0) {
- setAllTeams(teamsData);
- }
-
- // Load all organizations
- const orgsData = await fetchAllOrganizations(accessToken);
- if (orgsData.length > 0) {
- setAllOrganizations(orgsData);
- }
- };
-
- if (accessToken) {
- loadAllFilterData();
- }
- }, [accessToken]);
-
- // Update teams and organizations when props change
- useEffect(() => {
- if (teams && teams.length > 0) {
- setAllTeams((prevTeams) => {
- // Only update if we don't already have a larger set of teams
- return prevTeams.length < teams.length ? teams : prevTeams;
- });
- }
- }, [teams]);
-
- useEffect(() => {
- if (organizations && organizations.length > 0) {
- setAllOrganizations((prevOrgs) => {
- // Only update if we don't already have a larger set of organizations
- return prevOrgs.length < organizations.length ? organizations : prevOrgs;
- });
- }
- }, [organizations]);
-
- const handleFilterChange = (newFilters: Record, skipDebounce: boolean = false) => {
- // Update filters state
- setFilters({
- "Team ID": newFilters["Team ID"] || "",
- "Organization ID": newFilters["Organization ID"] || "",
- "Key Alias": newFilters["Key Alias"] || "",
- "User ID": newFilters["User ID"] || "",
- "Sort By": newFilters["Sort By"] || "created_at",
- "Sort Order": newFilters["Sort Order"] || "desc",
- });
-
- // Only trigger debouncedSearch if skipDebounce is false
- // This allows sorting to be handled by the parent component's useKeys hook
- if (!skipDebounce) {
- // Fetch keys based on new filters
- const updatedFilters = {
- ...filters,
- ...newFilters,
- };
- debouncedSearch(updatedFilters);
- }
- };
-
- const handleFilterReset = () => {
- // Reset filters state
- setFilters(defaultFilters);
- setFilteredTotalCount(null);
-
- // Reset selections
- debouncedSearch(defaultFilters);
- };
-
- return {
- filters,
- filteredKeys,
- filteredTotalCount,
- allTeams,
- allOrganizations,
- handleFilterChange,
- handleFilterReset,
- };
-}
diff --git a/ui/litellm-dashboard/src/components/mcp_tools/MCPSubmissionsTab.tsx b/ui/litellm-dashboard/src/components/mcp_tools/MCPSubmissionsTab.tsx
index fc0a0779b24..100373a4571 100644
--- a/ui/litellm-dashboard/src/components/mcp_tools/MCPSubmissionsTab.tsx
+++ b/ui/litellm-dashboard/src/components/mcp_tools/MCPSubmissionsTab.tsx
@@ -97,7 +97,9 @@ function ConfirmDialog({ action, serverName, isCurrentlyActive, onConfirm, onCan
Are you sure you want to {action} "{serverName}"?{" "}
- {isApprove ? "This will make it active and available for use." : rejectBody}
+ {isApprove
+ ? "This will activate the server. The submitting user will see it in their MCP Servers list once approved."
+ : rejectBody}
{!isApprove && (
)}
- {toolsError && (
+ {toolsError && !isPreviewForbidden && (