Merge pull request #33514 from ozolam/litellm_fix_skills_marketplace_commands_v2

fix(UI): correct skill install command and marketplace setup UX
This commit is contained in:
ryan-crabbe-berri 2026-08-24 10:17:25 -07:00 • committed by GitHub
commit 4913b2a3ca
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 216 additions and 49 deletions

View file

@ -6,21 +6,21 @@ Plugins are stored as metadata + git source references in LiteLLM database.
Actual plugin files are hosted on GitHub/GitLab/Bitbucket.
Endpoints:
/claude-code/marketplace.json - GET - List plugins for Claude Code discovery
/claude-code/plugins - POST - Register a new plugin (create-only)
/claude-code/plugins - GET - List plugins (admin)
/claude-code/plugins/{name} - GET - Get plugin details
/claude-code/plugins/{name} - PUT - Update an existing plugin
/claude-code/plugins/{name}/enable - POST - Enable a plugin
/claude-code/plugins/{name}/disable - POST - Disable a plugin
/claude-code/plugins/{name} - DELETE - Delete a plugin
/claude-code/marketplace.json - GET - List plugins for Claude Code discovery (unauthenticated)
/claude-code/plugins - POST - Register a new plugin (create-only, proxy admin only)
/claude-code/plugins - GET - List plugins (any authenticated key)
/claude-code/plugins/{name} - GET - Get plugin details (any authenticated key)
/claude-code/plugins/{name} - PUT - Update an existing plugin (proxy admin only)
/claude-code/plugins/{name}/enable - POST - Enable a plugin (proxy admin only)
/claude-code/plugins/{name}/disable - POST - Disable a plugin (proxy admin only)
/claude-code/plugins/{name} - DELETE - Delete a plugin (proxy admin only)
"""
import json
import re
from collections.abc import Mapping, Sequence
from datetime import datetime, timezone
from typing import Final, Protocol, TypedDict
from typing import Annotated, Final, Protocol, TypedDict
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import JSONResponse
@ -28,6 +28,7 @@ from fastapi.responses import JSONResponse
from litellm._logging import verbose_proxy_logger
from litellm.proxy._types import CommonProxyErrors, UserAPIKeyAuth
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.common_utils.resource_ownership import is_proxy_admin
from litellm.repositories.table_repositories import ClaudeCodePluginRepository
from litellm.types.proxy.claude_code_endpoints import (
ListPluginsResponse,
@ -221,6 +222,18 @@ def _name_conflict_error(name: str) -> HTTPException:
)
def _require_proxy_admin(user_api_key_dict: UserAPIKeyAuth) -> None:
"""Catalog mutations are restricted to proxy admins: marketplace.json is served
unauthenticated and any registered/updated entry is immediately installable by
every user, so a non-admin key must never be able to add or overwrite one.
"""
if not is_proxy_admin(user_api_key_dict):
raise HTTPException(
status_code=403,
detail={"error": "Only proxy admins may modify the Claude Code plugin marketplace."},
)
@router.post(
"/claude-code/plugins",
tags=["Claude Code Marketplace"],
@ -242,6 +255,8 @@ async def register_plugin(
the same name already exists it returns 409 Conflict; use
PUT /claude-code/plugins/{plugin_name} to update an existing plugin.
Requires a proxy admin API key.
Parameters:
- name: Plugin name (kebab-case)
- source: Git source reference (github, url, or git-subdir format)
@ -271,6 +286,8 @@ async def register_plugin(
from prisma.errors import UniqueViolationError
try:
_require_proxy_admin(user_api_key_dict)
prisma_client: Final = await _get_prisma_client()
if not re.match(r"^[a-z0-9-]+$", request.name):
@ -468,6 +485,7 @@ async def get_plugin(
async def update_plugin(
plugin_name: str,
request: UpdatePluginRequest,
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
):
"""
Update an existing plugin in the LiteLLM marketplace.
@ -481,6 +499,8 @@ async def update_plugin(
Returns 404 if no plugin with the given name exists; use
POST /claude-code/plugins to create a new plugin.
Requires a proxy admin API key.
Parameters:
- plugin_name: Name of the plugin to update (path parameter)
- source: Git source reference (github, url, or git-subdir format)
@ -509,6 +529,8 @@ async def update_plugin(
from prisma.errors import PrismaError
try:
_require_proxy_admin(user_api_key_dict)
prisma_client: Final = await _get_prisma_client()
_validate_plugin_source(request.source)
@ -566,10 +588,14 @@ async def enable_plugin(
"""
Enable a disabled plugin.
Requires a proxy admin API key.
Parameters:
- plugin_name: The name of the plugin to enable
"""
try:
_require_proxy_admin(user_api_key_dict)
prisma_client: Final = await _get_prisma_client()
plugin: Final[_PluginRecord | None] = await ClaudeCodePluginRepository(prisma_client).table.find_unique(
@ -611,10 +637,14 @@ async def disable_plugin(
"""
Disable a plugin without deleting it.
Requires a proxy admin API key.
Parameters:
- plugin_name: The name of the plugin to disable
"""
try:
_require_proxy_admin(user_api_key_dict)
prisma_client: Final = await _get_prisma_client()
plugin: Final[_PluginRecord | None] = await ClaudeCodePluginRepository(prisma_client).table.find_unique(
@ -656,10 +686,14 @@ async def delete_plugin(
"""
Delete a plugin from the marketplace.
Requires a proxy admin API key.
Parameters:
- plugin_name: The name of the plugin to delete
"""
try:
_require_proxy_admin(user_api_key_dict)
prisma_client: Final = await _get_prisma_client()
plugin: Final[_PluginRecord | None] = await ClaudeCodePluginRepository(prisma_client).table.find_unique(

View file

@ -18,6 +18,9 @@ from litellm.types.proxy.claude_code_endpoints import (
UpdatePluginRequest,
)
from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_marketplace import (
delete_plugin,
disable_plugin,
enable_plugin,
get_marketplace,
register_plugin,
update_plugin,
@ -72,6 +75,12 @@ _USER = UserAPIKeyAuth(
user_id="test-user",
)
_NON_ADMIN_USER = UserAPIKeyAuth(
user_role=LitellmUserRoles.INTERNAL_USER,
api_key="sk-5678",
user_id="regular-user",
)
_GIT_SUBDIR_SOURCE = {
"source": "git-subdir",
"url": "https://github.com/org/monorepo.git",
@ -151,6 +160,7 @@ async def test_update_plugin_replaces_existing_source():
response = await update_plugin(
plugin_name=name,
request=UpdatePluginRequest(source=new_source, version="2.0.0", description="updated"),
user_api_key_dict=_USER,
)
assert response.status == "success"
@ -170,6 +180,7 @@ async def test_update_plugin_not_found():
await update_plugin(
plugin_name="does-not-exist",
request=UpdatePluginRequest(source=_GIT_SUBDIR_SOURCE),
user_api_key_dict=_USER,
)
assert exc_info.value.status_code == 404
@ -213,6 +224,7 @@ async def test_update_plugin_db_error_maps_to_structured_500():
await update_plugin(
plugin_name=name,
request=UpdatePluginRequest(source={"source": "github", "repo": "org/replacement"}),
user_api_key_dict=_USER,
)
assert exc_info.value.status_code == 500
@ -341,3 +353,62 @@ async def test_register_plugin_unknown_source_type():
assert exc_info.value.status_code == 400
assert "git-subdir" in exc_info.value.detail["error"]
@pytest.mark.asyncio
async def test_register_plugin_rejects_non_admin():
"""A non-admin key cannot add an entry to the marketplace catalog."""
request = RegisterPluginRequest(name="attacker-plugin", source=_GIT_SUBDIR_SOURCE)
with pytest.raises(HTTPException) as exc_info:
await register_plugin(request=request, user_api_key_dict=_NON_ADMIN_USER)
assert exc_info.value.status_code == 403
table = litellm.proxy.proxy_server.prisma_client.db.litellm_claudecodeplugintable
assert await table.find_unique(where={"name": "attacker-plugin"}) is None
@pytest.mark.asyncio
async def test_update_plugin_rejects_non_admin_overwrite():
"""A non-admin key cannot overwrite an existing plugin's source."""
name = "trusted-plugin"
await register_plugin(
request=RegisterPluginRequest(name=name, source=_GIT_SUBDIR_SOURCE, version="1.0.0"),
user_api_key_dict=_USER,
)
malicious_source = {"source": "github", "repo": "attacker/malicious-repo"}
with pytest.raises(HTTPException) as exc_info:
await update_plugin(
plugin_name=name,
request=UpdatePluginRequest(source=malicious_source),
user_api_key_dict=_NON_ADMIN_USER,
)
assert exc_info.value.status_code == 403
stored = await _read_stored_manifest(name)
assert stored["source"] == _GIT_SUBDIR_SOURCE
@pytest.mark.asyncio
async def test_enable_disable_delete_plugin_reject_non_admin():
"""Non-admin keys cannot enable, disable, or delete catalog entries."""
name = "trusted-plugin-2"
await register_plugin(
request=RegisterPluginRequest(name=name, source=_GIT_SUBDIR_SOURCE, version="1.0.0"),
user_api_key_dict=_USER,
)
for coro in (
enable_plugin(plugin_name=name, user_api_key_dict=_NON_ADMIN_USER),
disable_plugin(plugin_name=name, user_api_key_dict=_NON_ADMIN_USER),
delete_plugin(plugin_name=name, user_api_key_dict=_NON_ADMIN_USER),
):
with pytest.raises(HTTPException) as exc_info:
await coro
assert exc_info.value.status_code == 403
table = litellm.proxy.proxy_server.prisma_client.db.litellm_claudecodeplugintable
assert (await table.find_unique(where={"name": name})).enabled is True

View file

@ -19,13 +19,13 @@ import {
isValidSubPath,
buildMarketplaceSettingsSnippet,
} from "./helpers";
import { MarketplacePluginEntry, PluginSource } from "./types";
import { MarketplacePluginEntry } from "./types";
describe("buildMarketplaceSettingsSnippet", () => {
it("nests the url under a source object so Claude Code accepts the marketplace", () => {
expect(JSON.parse(buildMarketplaceSettingsSnippet("https://proxy.example.com"))).toEqual({
extraKnownMarketplaces: {
"my-org": {
litellm: {
source: {
source: "url",
url: "https://proxy.example.com/claude-code/marketplace.json",
@ -37,28 +37,12 @@ describe("buildMarketplaceSettingsSnippet", () => {
});
describe("formatInstallCommand", () => {
it("formats github source with repo", () => {
const source: PluginSource = { source: "github", repo: "org/repo" };
expect(formatInstallCommand({ name: "my-plugin", source })).toBe("/plugin marketplace add org/repo");
it("produces a /plugin install command scoped to the litellm marketplace", () => {
expect(formatInstallCommand({ name: "my-plugin" })).toBe("/plugin install my-plugin@litellm");
});
it("formats url source", () => {
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 source: PluginSource = { source: "github" };
expect(formatInstallCommand({ name: "my-plugin", source })).toBe("/plugin marketplace add my-plugin");
it("uses the plugin name as the identifier", () => {
expect(formatInstallCommand({ name: "code-review" })).toBe("/plugin install code-review@litellm");
});
});

View file

@ -179,13 +179,14 @@ export const parseSkillSource = (rawUrl: string, subPath?: string): SkillSourceP
/**
* Build the `~/.claude/settings.json` snippet that registers the proxy as a marketplace.
* Claude Code expects `extraKnownMarketplaces.<name>.source` to be a source object, not a
* bare `"url"` string, so the url/source pair is nested one level deeper.
* bare `"url"` string, so the url/source pair is nested one level deeper. The key must be
* "litellm" to match the name the proxy returns in marketplace.json.
*/
export const buildMarketplaceSettingsSnippet = (proxyOrigin: string): string =>
JSON.stringify(
{
extraKnownMarketplaces: {
"my-org": {
litellm: {
source: {
source: "url",
url: `${proxyOrigin}/claude-code/marketplace.json`,
@ -198,20 +199,10 @@ export const buildMarketplaceSettingsSnippet = (proxyOrigin: string): string =>
);
/**
* Generate install command for Claude Code CLI
* Format: /plugin marketplace add org/repo OR /plugin marketplace add url
* Generate install command for Claude Code CLI.
* Installs the named plugin from the "litellm" marketplace registered in settings.json.
*/
export const formatInstallCommand = (plugin: { name: string; source: PluginSource }): string => {
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}`;
};
export const formatInstallCommand = (plugin: { name: string }): string => `/plugin install ${plugin.name}@litellm`;
/**
* Extract unique categories from plugins list

View file

@ -261,6 +261,32 @@ const SkillDetail: React.FC<SkillDetailProps> = ({ skill, onBack }) => {
</pre>
</div>
{/* Shown when the marketplace catalog is stale and the plugin isn't found yet */}
<div
style={{
border: "1px solid #fce8b2",
borderRadius: 8,
padding: "12px 16px",
backgroundColor: "#fefce8",
marginBottom: 16,
}}
>
<p style={{ fontSize: 13, color: "#5f6368", lineHeight: 1.6, margin: "0 0 8px 0" }}>
If you see &quot;Plugin {skill.name} not found in marketplace&quot;, update the catalog first:
</p>
<pre
style={{
margin: 0,
fontSize: 13,
fontFamily: "monospace",
color: "#202124",
backgroundColor: "transparent",
}}
>
/plugin marketplace update litellm
</pre>
</div>
<p style={{ fontSize: 13, color: "#5f6368", lineHeight: 1.6, margin: 0 }}>
Don&apos;t have the marketplace configured yet?{" "}
<span onClick={() => setActiveTab("setup")} style={{ color: "#1a73e8", cursor: "pointer" }}>
@ -276,12 +302,73 @@ const SkillDetail: React.FC<SkillDetailProps> = ({ skill, onBack }) => {
<h2 style={{ fontSize: 18, fontWeight: 400, color: "#202124", margin: "0 0 8px 0" }}>
One-time marketplace setup
</h2>
<p style={{ fontSize: 14, color: "#5f6368", margin: "0 0 24px 0", lineHeight: 1.6 }}>
Add this to{" "}
{/* Option 1: single command — fastest path for most users */}
<p style={{ fontSize: 14, color: "#5f6368", margin: "0 0 12px 0", lineHeight: 1.6 }}>
Run this command in Claude Code to register the marketplace:
</p>
<div
style={{
border: "1px solid #dadce0",
borderRadius: 8,
overflow: "hidden",
marginBottom: 24,
}}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "10px 16px",
backgroundColor: "#f8f9fa",
borderBottom: "1px solid #dadce0",
}}
>
<span style={{ fontSize: 13, color: "#3c4043", fontWeight: 500 }}>Run in Claude Code</span>
<button
onClick={() => {
const origin = typeof window !== "undefined" ? window.location.origin : "";
copyToClipboard(`/plugin marketplace add ${origin}/claude-code/marketplace.json`, "marketplace-cmd");
}}
style={{
display: "flex",
alignItems: "center",
gap: 4,
fontSize: 12,
color: copiedKey === "marketplace-cmd" ? "#137333" : "#1a73e8",
background: "none",
border: "none",
cursor: "pointer",
padding: 0,
}}
>
{copiedKey === "marketplace-cmd" ? <CheckOutlined /> : <CopyOutlined />}
{copiedKey === "marketplace-cmd" ? "Copied" : "Copy"}
</button>
</div>
<pre
style={{
margin: 0,
padding: "14px 16px",
fontSize: 13,
fontFamily: "monospace",
color: "#202124",
backgroundColor: "#fff",
}}
>
{`/plugin marketplace add ${typeof window !== "undefined" ? window.location.origin : "<proxy-url>"}/claude-code/marketplace.json`}
</pre>
</div>
{/* Option 2: settings.json — for persistent config or managed deployments.
extraKnownMarketplaces requires source to be a nested object, not a flat string. */}
<p style={{ fontSize: 14, color: "#5f6368", margin: "0 0 12px 0", lineHeight: 1.6 }}>
Or add this to{" "}
<code style={{ fontSize: 13, backgroundColor: "#f1f3f4", padding: "1px 6px", borderRadius: 4 }}>
~/.claude/settings.json
</code>{" "}
to point Claude Code at your proxy:
for a persistent configuration:
</p>
<div
style={{