diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json
index b72beea0ba9..b99fc8b61e1 100644
--- a/litellm/proxy/_lazy_openapi_snapshot.json
+++ b/litellm/proxy/_lazy_openapi_snapshot.json
@@ -60243,6 +60243,47 @@
"title": "SkillHubRejection",
"type": "object"
},
+ "SkillHubRestore": {
+ "additionalProperties": false,
+ "properties": {
+ "expected_skill_id": {
+ "description": "Current skill ID read by the editor",
+ "title": "Expected Skill Id",
+ "type": "string"
+ },
+ "expected_updated_at": {
+ "anyOf": [
+ {
+ "format": "date-time",
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Current updated_at read by the editor, including null",
+ "title": "Expected Updated At"
+ },
+ "version": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Higher semantic version, or omit/null for an automatic patch bump",
+ "title": "Version"
+ }
+ },
+ "required": [
+ "expected_skill_id",
+ "expected_updated_at"
+ ],
+ "title": "SkillHubRestore",
+ "type": "object"
+ },
"SkillHubReviewResponse": {
"properties": {
"action": {
@@ -61914,6 +61955,73 @@
]
}
},
+ "/skill-hub/skills/{name}/versions/{version_id}/restore": {
+ "post": {
+ "description": "Proxy admins may republish retained bytes and metadata as a new version, preserving visibility and grants.",
+ "operationId": "restore_skill_version_skill_hub_skills__name__versions__version_id__restore_post",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "name",
+ "required": true,
+ "schema": {
+ "title": "Name",
+ "type": "string"
+ }
+ },
+ {
+ "in": "path",
+ "name": "version_id",
+ "required": true,
+ "schema": {
+ "title": "Version Id",
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/SkillHubRestore"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/SkillHubSkill"
+ }
+ }
+ },
+ "description": "Successful Response"
+ },
+ "422": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ },
+ "description": "Validation Error"
+ }
+ },
+ "security": [
+ {
+ "APIKeyHeader": []
+ }
+ ],
+ "summary": "Restore Skill Version",
+ "tags": [
+ "skill_hub"
+ ]
+ }
+ },
"/skill-hub/skills/{plugin_name}/downloads": {
"get": {
"description": "Admin-only ZIP download totals and authenticated downloader history.\n\nAnonymous downloads contribute only to totals. Counts include repeat requests and client prefetches,\nstarting when tracking is deployed. External sources and installation completion are not observed.",
diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py
index d4169d3ae04..c29c5e9e01a 100644
--- a/litellm/proxy/_types.py
+++ b/litellm/proxy/_types.py
@@ -463,6 +463,7 @@ class LiteLLMRoutes(enum.Enum):
"/skill-hub/skills/{name}/versions",
"/skill-hub/skills/{name}/versions/{version_id}",
"/skill-hub/skills/{name}/versions/{version_id}/archive",
+ "/skill-hub/skills/{name}/versions/{version_id}/restore",
"/skill-hub/skills/{name}/publish",
"/skill-hub/skills/{name}/unpublish",
"/skill-hub/skills/{name}/upload",
diff --git a/litellm/proxy/discovery_endpoints/skill_hub_endpoints.py b/litellm/proxy/discovery_endpoints/skill_hub_endpoints.py
index ffddbd31048..8cbcf6d52ea 100644
--- a/litellm/proxy/discovery_endpoints/skill_hub_endpoints.py
+++ b/litellm/proxy/discovery_endpoints/skill_hub_endpoints.py
@@ -19,6 +19,8 @@ from litellm.models.plugin_upload import (
PluginDownloader,
PluginDownloadsResponse,
PluginPublication,
+ PluginPublicationBase,
+ PluginPublicationError,
PluginSnapshot,
PluginUploadStore,
PluginVersion,
@@ -56,6 +58,7 @@ from litellm.proxy.discovery_endpoints.agent_skills_archive import (
from litellm.proxy.discovery_endpoints.skill_hub_mutations import (
SkillMutationError,
archive_writer,
+ publication_error,
write_skill,
write_target,
)
@@ -77,6 +80,7 @@ from litellm.types.proxy.skill_hub import (
SkillHubPendingSkill,
SkillHubProposal,
SkillHubPublication,
+ SkillHubRestore,
SkillHubSkill,
SkillHubVersion,
SkillHubVersions,
@@ -635,6 +639,54 @@ async def download_skill_version(
)
+@router.post("/{name}/versions/{version_id}/restore", response_model=SkillHubSkill)
+async def restore_skill_version(
+ request: Request,
+ name: str,
+ version_id: str,
+ body: SkillHubRestore,
+ uploads: PluginUploadRepositoryDep,
+ caller: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
+) -> SkillHubSkill:
+ """Proxy admins may republish retained bytes and metadata as a new version, preserving visibility and grants."""
+ _require_admin(caller)
+ store: Final = _require_store(uploads)
+ metadata: Final = await _version_metadata(name, store, caller)
+ if metadata.id != body.expected_skill_id or metadata.updated_at != body.expected_updated_at:
+ raise HTTPException(409, "The published skill changed. Reload it before restoring a version")
+ version: Final = await store.version(metadata, version_id)
+ if version is None:
+ raise HTTPException(404, "Skill version not found")
+ content: Final = await store.version_content(metadata, version_id) if version.archive_available else None
+ if content is None:
+ raise HTTPException(409, "This version has no verified retained archive to restore")
+ digest: Final = hashlib.sha256(content).hexdigest()
+ review: Final = read_plugin_review(version.manifest)
+ publication: Final = PluginPublication(
+ name=name,
+ version=body.version,
+ description=version.description,
+ manifest={
+ **version.manifest,
+ "source": {
+ "source": "archive",
+ "url": plugin_archive_url(request, name, digest),
+ "sha256": digest,
+ },
+ },
+ content=content,
+ created_by=caller.user_id or "proxy-admin",
+ review=replace(review, approved_by=caller.user_id or "proxy-admin") if review is not None else None,
+ )
+ published: Final = await store.publish(
+ publication, PluginPublicationBase(body.expected_skill_id, body.expected_updated_at)
+ )
+ if isinstance(published, PluginPublicationError):
+ error: Final = publication_error(published)
+ raise HTTPException(error.status, error.message)
+ return skill_publication_response(request, publication, published)
+
+
async def _visible_archive(request: Request, name: str, uploads: PluginUploadStore | None) -> VisibleSkillArchive:
anonymous_allowed: Final = litellm.public_skills_index is True and not marketplace_auth_required()
caller: Final = (
diff --git a/litellm/types/proxy/skill_hub.py b/litellm/types/proxy/skill_hub.py
index 050c3cb7eb8..9d866f51feb 100644
--- a/litellm/types/proxy/skill_hub.py
+++ b/litellm/types/proxy/skill_hub.py
@@ -78,6 +78,14 @@ class SkillHubPublication(LiteLLMBaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
+class SkillHubRestore(LiteLLMBaseModel):
+ model_config = ConfigDict(frozen=True, extra="forbid")
+
+ expected_skill_id: str = Field(description="Current skill ID read by the editor")
+ expected_updated_at: datetime | None = Field(description="Current updated_at read by the editor, including null")
+ version: str | None = Field(None, description="Higher semantic version, or omit/null for an automatic patch bump")
+
+
class SkillHubVersion(LiteLLMBaseModel):
model_config = ConfigDict(frozen=True)
diff --git a/tests/unit/proxy/discovery_endpoints/test_skill_hub_endpoints.py b/tests/unit/proxy/discovery_endpoints/test_skill_hub_endpoints.py
index 7912df3939b..19c879379bd 100644
--- a/tests/unit/proxy/discovery_endpoints/test_skill_hub_endpoints.py
+++ b/tests/unit/proxy/discovery_endpoints/test_skill_hub_endpoints.py
@@ -901,3 +901,147 @@ async def test_catalog_sorts_all_matches_before_pagination(
})
assert response.status_code == 200, response.text
assert [skill["name"] for skill in response.json()["skills"]] == [expected]
+
+
+async def _restorable_version(hub: HubClient) -> prisma_models.LiteLLM_ClaudeCodePluginVersion:
+ assert (await _upload(hub, _zip(FILES), version="1.0.0")).status_code == 200
+ original: Final = _stored_upload(hub, enabled=False)
+ history: Final = hub.database.litellm_claudecodepluginversion
+ retained: Final = prisma_models.LiteLLM_ClaudeCodePluginVersion.model_validate({
+ "id": "retained", **history.create.await_args.kwargs["data"],
+ })
+ history.count.return_value = 1
+ history.find_first.return_value = retained
+ hub.database.query_raw.return_value = [{
+ "id": retained.id, "version": retained.version, "description": retained.description,
+ "manifest": json.loads(retained.manifest_json), "published_at": retained.published_at,
+ "published_by": retained.published_by, "archive_available": True,
+ }]
+ hub.plugins.find_unique.return_value = original.model_copy(update={
+ "version": "2.0.0", "description": "Current description",
+ "updated_at": datetime(2026, 1, 2),
+ })
+ return retained
+
+
+def _restore_body() -> dict[str, object]:
+ return {"expected_skill_id": "hub-id", "expected_updated_at": "2026-01-02T00:00:00"}
+
+
+@pytest.mark.asyncio
+async def test_restore_republishes_retained_bytes_and_metadata_with_new_version(hub: HubClient) -> None:
+ retained: Final = await _restorable_version(hub)
+ response: Final = await hub.client.post(
+ f"{HUB}/{NAME}/versions/retained/restore", headers=_headers(MASTER_KEY), json=_restore_body(),
+ )
+ assert response.status_code == 200, response.text
+ assert response.json()["version"] == "2.0.1"
+ assert response.json()["description"] == retained.description
+ assert response.json()["id"] == "hub-id"
+ assert response.json()["visibility"] == "private"
+ assert response.json()["approved_by"] is None
+ assert response.json()["security_scan_passed"] is False
+ latest: Final = hub.database.litellm_claudecodepluginversion.create.await_args.kwargs["data"]
+ assert latest["content"] == retained.content
+ assert latest["version"] == "2.0.1"
+ assert json.loads(latest["manifest_json"])["agent_skill"] == json.loads(retained.manifest_json)["agent_skill"]
+ assert retained.version == "1.0.0"
+ assert latest["published_by"] == response.json()["published_by"]
+
+
+@pytest.mark.parametrize("scan_passed", (True, False))
+@pytest.mark.asyncio
+async def test_restore_carries_retained_scan_and_credits_restoring_admin(hub: HubClient, scan_passed: bool) -> None:
+ retained: Final = await _restorable_version(hub)
+ manifest: Final = {
+ **json.loads(retained.manifest_json),
+ "_litellm_review": {"approved_by": "previous-admin", "security_scan_passed": scan_passed},
+ }
+ hub.database.query_raw.return_value = [
+ {**row, "manifest": manifest} for row in hub.database.query_raw.return_value
+ ]
+ hub.database.litellm_claudecodepluginversion.find_first.return_value = retained.model_copy(update={
+ "manifest_json": json.dumps(manifest),
+ })
+ response: Final = await hub.client.post(
+ f"{HUB}/{NAME}/versions/retained/restore", headers=_headers(MASTER_KEY), json=_restore_body(),
+ )
+ assert response.status_code == 200, response.text
+ assert response.json()["security_scan_passed"] is scan_passed
+ assert response.json()["approved_by"] == response.json()["published_by"] == "default_user_id"
+ latest: Final = hub.database.litellm_claudecodepluginversion.create.await_args.kwargs["data"]
+ assert json.loads(latest["manifest_json"])["_litellm_review"] == {
+ "approved_by": "default_user_id", "security_scan_passed": scan_passed,
+ }
+ assert latest["published_by"] == "default_user_id"
+
+
+@pytest.mark.parametrize("key,status", (
+ (GRANTED_KEY, 403), (PLAIN_KEY, 403), (MEMBER_KEY, 403), (VIEWER_KEY, 403), (None, 401),
+))
+@pytest.mark.asyncio
+async def test_restore_rejects_nonadmins_even_with_skill_grants(hub: HubClient, key: str | None, status: int) -> None:
+ await _restorable_version(hub)
+ hub.plugins.update.reset_mock()
+ response: Final = await hub.client.post(
+ f"{HUB}/{NAME}/versions/retained/restore", headers=_headers(key), json=_restore_body(),
+ )
+ assert response.status_code == status, response.text
+ hub.plugins.update.assert_not_awaited()
+
+
+@pytest.mark.parametrize("change,status", (
+ ({"expected_skill_id": "deleted-skill"}, 409),
+ ({"expected_updated_at": "2026-01-01T00:00:00"}, 409),
+ ({"expected_updated_at": None}, 409),
+ ({"version": "invalid"}, 400),
+ ({"version": "1.0.0"}, 400),
+ ({"_litellm_review": {"approved_by": "forged", "security_scan_passed": True}}, 422),
+))
+@pytest.mark.asyncio
+async def test_restore_rejects_stale_state_and_invalid_requests(
+ hub: HubClient, change: Mapping[str, object], status: int,
+) -> None:
+ await _restorable_version(hub)
+ hub.plugins.update.reset_mock()
+ response: Final = await hub.client.post(
+ f"{HUB}/{NAME}/versions/retained/restore", headers=_headers(MASTER_KEY),
+ json={**_restore_body(), **change},
+ )
+ assert response.status_code == status, response.text
+ hub.plugins.update.assert_not_awaited()
+
+
+@pytest.mark.parametrize("missing,status", (("version", 404), ("content", 409), ("source_reference", 409)))
+@pytest.mark.asyncio
+async def test_restore_requires_a_retained_verified_archive(hub: HubClient, missing: str, status: int) -> None:
+ await _restorable_version(hub)
+ if missing == "version":
+ hub.database.query_raw.return_value = []
+ elif missing == "source_reference":
+ hub.database.query_raw.return_value = [
+ {**row, "archive_available": False} for row in hub.database.query_raw.return_value
+ ]
+ else:
+ hub.database.litellm_claudecodepluginversion.find_first.return_value = None
+ hub.plugins.update.reset_mock()
+ response: Final = await hub.client.post(
+ f"{HUB}/{NAME}/versions/retained/restore", headers=_headers(MASTER_KEY), json=_restore_body(),
+ )
+ assert response.status_code == status, response.text
+ hub.plugins.update.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_restore_checks_the_snapshot_again_under_the_publication_lock(hub: HubClient) -> None:
+ await _restorable_version(hub)
+ current: Final = hub.plugins.find_unique.return_value
+ hub.plugins.find_unique.side_effect = [
+ current, current.model_copy(update={"updated_at": datetime(2026, 1, 3)}),
+ ]
+ hub.plugins.update.reset_mock()
+ response: Final = await hub.client.post(
+ f"{HUB}/{NAME}/versions/retained/restore", headers=_headers(MASTER_KEY), json=_restore_body(),
+ )
+ assert response.status_code == 409, response.text
+ hub.plugins.update.assert_not_awaited()
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.tsx
index 6497c0922d2..d0ca11f829a 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.tsx
@@ -67,8 +67,10 @@ function CatalogSkillDetail({
onEdit,
}: CatalogSkillDetailProps) {
const { t } = useTranslation();
+ const queryClient = useQueryClient();
+ const detailKey = [...queryScope, "detail", skill.id, skill.name];
const detail = useQuery({
- queryKey: [...queryScope, "detail", skill.id, skill.name],
+ queryKey: detailKey,
queryFn: () => getSkillHubSkill(accessToken!, skill.name),
enabled: Boolean(accessToken),
});
@@ -87,6 +89,7 @@ function CatalogSkillDetail({
onBack={onBack}
accessToken={accessToken}
isAdmin={isAdmin}
+ onRestored={(restored) => queryClient.setQueryData(detailKey, restored)}
onEditClick={canWrite && detail.isSuccess && !detail.isFetching ? () => onEdit(detail.data) : undefined}
/>
>
diff --git a/ui/litellm-dashboard/src/components/AIHub/SkillHubDashboard.tsx b/ui/litellm-dashboard/src/components/AIHub/SkillHubDashboard.tsx
index 3099da3476e..874837863a0 100644
--- a/ui/litellm-dashboard/src/components/AIHub/SkillHubDashboard.tsx
+++ b/ui/litellm-dashboard/src/components/AIHub/SkillHubDashboard.tsx
@@ -99,6 +99,10 @@ const SkillHubDashboard: React.FC