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/model_info_view.test.tsx b/ui/litellm-dashboard/src/components/model_info_view.test.tsx
index d91d5ec307e..2546601b4db 100644
--- a/ui/litellm-dashboard/src/components/model_info_view.test.tsx
+++ b/ui/litellm-dashboard/src/components/model_info_view.test.tsx
@@ -633,6 +633,51 @@ describe("ModelInfoView", () => {
expect(updatePayload.litellm_params).not.toHaveProperty("output_cost_per_token");
});
+ it("never re-sends a masked secret on save (regression: masked auth value must not overwrite the real secret)", async () => {
+ // /model/info redacts secrets by masking (e.g. "azur****BBCC"), not removing them.
+ // A plain save re-PATCHes the whole litellm_params blob; if the masked value were
+ // sent, the backend would encrypt the asterisks over the real azure_ad_token and
+ // silently destroy the credential. The edit form must strip masked values entirely.
+ const maskedSecret = "azur********************************************BBCC";
+ const maskedModelData = {
+ ...defaultModelData,
+ litellm_params: {
+ model: "azure/gpt-4o",
+ api_base: "https://example-az.openai.azure.com",
+ custom_llm_provider: "azure",
+ azure_ad_token: maskedSecret,
+ },
+ };
+ mockUseModelsInfo.mockReturnValue({
+ data: { data: [maskedModelData] },
+ isLoading: false,
+ error: null,
+ });
+ mockModelInfoV1Call.mockResolvedValue({ data: [maskedModelData] });
+
+ const user = userEvent.setup();
+ render(, { wrapper });
+
+ await waitFor(() => {
+ expect(screen.getByRole("button", { name: /edit settings/i })).toBeInTheDocument();
+ });
+ await user.click(screen.getByRole("button", { name: /edit settings/i }));
+
+ await waitFor(() => {
+ expect(screen.getByRole("button", { name: /save changes/i })).toBeInTheDocument();
+ });
+ await user.click(screen.getByRole("button", { name: /save changes/i }));
+
+ await waitFor(() => {
+ expect(mockModelPatchUpdateCall).toHaveBeenCalled();
+ });
+
+ const updatePayload = mockModelPatchUpdateCall.mock.calls[0][1];
+ expect(updatePayload.litellm_params.azure_ad_token).not.toBe(maskedSecret);
+ // No masked value may appear anywhere in the outbound params.
+ expect(JSON.stringify(updatePayload.litellm_params)).not.toContain("**");
+ });
+
it("should display health check model field for wildcard models", async () => {
const wildcardModelData = {
...defaultModelData,
diff --git a/ui/litellm-dashboard/src/components/model_info_view.tsx b/ui/litellm-dashboard/src/components/model_info_view.tsx
index 66a00b9bbe3..45c5b0fd9b6 100644
--- a/ui/litellm-dashboard/src/components/model_info_view.tsx
+++ b/ui/litellm-dashboard/src/components/model_info_view.tsx
@@ -1,5 +1,6 @@
import { useModelCostMap } from "@/app/(dashboard)/hooks/models/useModelCostMap";
import { useModelHub, useModelsInfo } from "@/app/(dashboard)/hooks/models/useModels";
+import { useQueryClient } from "@tanstack/react-query";
import { transformModelData } from "@/app/(dashboard)/models-and-endpoints/utils/modelDataTransformer";
import { InfoCircleOutlined } from "@ant-design/icons";
import { ArrowLeftIcon, KeyIcon, RefreshIcon, TrashIcon } from "@heroicons/react/outline";
@@ -40,6 +41,7 @@ import {
testConnectionRequest,
} from "./networking";
import { getProviderLogoAndName } from "./provider_info_helpers";
+import UpdateModelCredentialsModal from "./update_model_credentials_modal";
import NumericalInput from "./shared/numerical_input";
import { Tag } from "./tag_management/types";
import { getDisplayModelName } from "./view_model/model_name_display";
@@ -54,6 +56,18 @@ interface ModelInfoViewProps {
modelAccessGroups: string[] | null;
}
+// The /model/info response redacts secrets by masking them (e.g. "sk-1****2345"),
+// not by removing them. The edit form must never echo a masked value back on save:
+// the backend would encrypt the asterisks and overwrite the real secret. A run of
+// 2+ mask chars only appears in masker output (real config — incl. wildcard model
+// names like "openai/*" — carries at most a single "*"), so this reliably detects a
+// redacted value without a provider-metadata lookup. API-key rotation goes through
+// UpdateModelCredentialsModal instead, which sends only the new key.
+const isMaskedSecret = (value: unknown): boolean => typeof value === "string" && /\*{2,}/.test(value);
+
+const stripMaskedSecrets = (params: Record): Record =>
+ Object.fromEntries(Object.entries(params).filter(([, value]) => !isMaskedSecret(value)));
+
export default function ModelInfoView({
modelId,
onClose,
@@ -64,10 +78,12 @@ export default function ModelInfoView({
modelAccessGroups,
}: ModelInfoViewProps) {
const [form] = Form.useForm();
+ const queryClient = useQueryClient();
const [localModelData, setLocalModelData] = useState(null);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [deleteLoading, setDeleteLoading] = useState(false);
const [isCredentialModalOpen, setIsCredentialModalOpen] = useState(false);
+ const [isUpdateCredentialsModalOpen, setIsUpdateCredentialsModalOpen] = useState(false);
const [isDirty, setIsDirty] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [isEditing, setIsEditing] = useState(false);
@@ -351,9 +367,15 @@ export default function ModelInfoView({
return;
}
+ // Final guard: never PATCH a redacted secret. The /model/info snapshot that
+ // seeds this form masks secrets, and any save re-sends the whole params blob;
+ // without this strip a masked value would be re-encrypted over the real secret.
+ // Credential rotation has its own dedicated path (UpdateModelCredentialsModal).
+ const safeLitellmParams = stripMaskedSecrets(updatedLitellmParams);
+
const updateData = {
model_name: values.model_name,
- litellm_params: updatedLitellmParams,
+ litellm_params: safeLitellmParams,
model_info: updatedModelInfo,
};
@@ -363,7 +385,7 @@ export default function ModelInfoView({
...localModelData,
model_name: values.model_name,
litellm_model_name: values.litellm_model_name,
- litellm_params: updatedLitellmParams,
+ litellm_params: safeLitellmParams,
model_info: updatedModelInfo,
};
@@ -511,36 +533,44 @@ export default function ModelInfoView({