mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-08 03:08:45 +00:00
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:
commit
4913b2a3ca
5 changed files with 216 additions and 49 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 "Plugin {skill.name} not found in marketplace", 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'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={{
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue