From 45a31852a3ba2335b11e730fd92f5b2af93627cb Mon Sep 17 00:00:00 2001 From: tin-berri Date: Wed, 11 Nov 2026 18:25:07 -0800 Subject: [PATCH] feat(skills): add admin-only version restore (#51309) * feat(skills): add admin-only version restore * fix(skills): retain verified scan evidence when restoring --- litellm/proxy/_lazy_openapi_snapshot.json | 108 +++++++++++++ litellm/proxy/_types.py | 1 + .../skill_hub_endpoints.py | 52 +++++++ litellm/types/proxy/skill_hub.py | 8 + .../test_skill_hub_endpoints.py | 144 ++++++++++++++++++ .../_components/ClaudeCodePluginsPanel.tsx | 5 +- .../components/AIHub/SkillHubDashboard.tsx | 4 + .../SkillVersionHistory.integration.test.tsx | 77 ++++++++++ .../SkillVersionHistory.tsx | 117 +++++++++++++- .../claude_code_plugins/skill_detail.tsx | 18 ++- ui/litellm-dashboard/src/i18n/locales/en.json | 10 +- ui/litellm-dashboard/src/lib/http/schema.d.ts | 74 +++++++++ 12 files changed, 611 insertions(+), 7 deletions(-) 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 = ({ isAdmin={isAdmin} accessToken={accessToken} onPublishClick={onPublishSuccess} + onRestored={(restored) => { + setSelectedSkill({ ...selectedSkill, ...restored, enabled: restored.visibility === "public" }); + onPublishSuccess?.(); + }} /> ); } diff --git a/ui/litellm-dashboard/src/components/claude_code_plugins/SkillVersionHistory.integration.test.tsx b/ui/litellm-dashboard/src/components/claude_code_plugins/SkillVersionHistory.integration.test.tsx index 7833ea65db6..0e03c288d51 100644 --- a/ui/litellm-dashboard/src/components/claude_code_plugins/SkillVersionHistory.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/claude_code_plugins/SkillVersionHistory.integration.test.tsx @@ -83,3 +83,80 @@ it("downloads through the neutral API client and reports a failed retained downl expect(String(fetchMock.mock.calls[1][0])).toContain("/skill-hub/skills/versioned/versions/retained/archive"); expect(String(fetchMock.mock.calls[1][0])).not.toContain("https://proxy.example"); }); + +const retained = { + id: "retained", + version: "1.0.0", + description: "Original instructions", + manifest: {}, + published_at: null, + published_by: null, + content_kind: "stored_archive", + download_url: "/skill-hub/skills/versioned/versions/retained/archive", +}; +const currentSkill = { ...skill, version: "2.0.0", updated_at: "2026-01-02T00:00:00Z" }; + +it.each([false, true])("only offers retained older versions to a writable admin (%s)", async (isAdmin) => { + fetchMock.mockResolvedValueOnce( + Response.json({ + versions: [ + retained, + { ...retained, id: "current", version: "2.0.0" }, + { ...retained, id: "external", version: "0.1.0", content_kind: "source_reference", download_url: null }, + ], + next_offset: null, + }), + ); + renderWithProviders( + , + ); + expect(await screen.findByText("1.0.0")).toBeInTheDocument(); + expect(screen.queryAllByRole("button", { name: "Restore this version" })).toHaveLength(isAdmin ? 1 : 0); +}); + +it("confirms a restore with the editor snapshot, updates the detail and refreshes history", async () => { + const onRestored = vi.fn(); + const restored = { ...currentSkill, version: "2.0.1", description: "Original instructions" }; + fetchMock + .mockResolvedValueOnce(Response.json({ versions: [retained], next_offset: null })) + .mockResolvedValueOnce(Response.json(restored)) + .mockResolvedValueOnce( + Response.json({ versions: [{ ...retained, id: "restored", version: "2.0.1" }, retained], next_offset: null }), + ); + renderWithProviders( + , + ); + fireEvent.click(await screen.findByRole("button", { name: "Restore this version" })); + expect(screen.getByRole("dialog")).toHaveTextContent("Existing versions, access permissions"); + expect(fetchMock).toHaveBeenCalledTimes(1); + fireEvent.change(screen.getByRole("textbox", { name: "New version (optional)" }), { target: { value: "2.0.1" } }); + fireEvent.click(screen.getByRole("button", { name: "Restore as new version" })); + expect(await screen.findByText("2.0.1")).toBeInTheDocument(); + expect(onRestored).toHaveBeenCalledWith(restored); + expect(String(fetchMock.mock.calls[1][0])).toContain("/skill-hub/skills/versioned/versions/retained/restore"); + expect(fetchMock.mock.calls[1][1]).toMatchObject({ + method: "POST", + headers: { Authorization: "Bearer test-token" }, + body: JSON.stringify({ + expected_skill_id: skill.id, + expected_updated_at: currentSkill.updated_at, + version: "2.0.1", + }), + }); +}); + +it("shows stale-restore errors without reporting success", async () => { + const onRestored = vi.fn(); + fetchMock + .mockResolvedValueOnce(Response.json({ versions: [retained], next_offset: null })) + .mockResolvedValueOnce( + Response.json({ detail: "The published skill changed. Reload it before restoring a version" }, { status: 409 }), + ); + renderWithProviders( + , + ); + fireEvent.click(await screen.findByRole("button", { name: "Restore this version" })); + fireEvent.click(screen.getByRole("button", { name: "Restore as new version" })); + expect(await screen.findByRole("alert")).toHaveTextContent("The published skill changed"); + expect(onRestored).not.toHaveBeenCalled(); +}); diff --git a/ui/litellm-dashboard/src/components/claude_code_plugins/SkillVersionHistory.tsx b/ui/litellm-dashboard/src/components/claude_code_plugins/SkillVersionHistory.tsx index ebfc9d8f525..83686492ac8 100644 --- a/ui/litellm-dashboard/src/components/claude_code_plugins/SkillVersionHistory.tsx +++ b/ui/litellm-dashboard/src/components/claude_code_plugins/SkillVersionHistory.tsx @@ -4,11 +4,15 @@ import { useTranslation } from "react-i18next"; import { downloadBlob } from "@/components/EntityUsageExport/utils"; import { apiClient } from "@/components/networking"; import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { ApiError } from "@/lib/http/client"; import type { components } from "@/lib/http/schema"; type Versions = components["schemas"]["SkillHubVersions"]; type PublishedVersion = components["schemas"]["SkillHubVersion"]; -type VersionedSkill = { id: string; name: string; updated_at?: string | null }; +type Skill = components["schemas"]["SkillHubSkill"]; +type VersionedSkill = Pick; function VersionArchive({ skillName, @@ -50,9 +54,105 @@ function VersionArchive({ ); } -export default function SkillVersionHistory({ skill, accessToken }: { skill: VersionedSkill; accessToken: string }) { +function RestoreVersion({ + skill, + version, + accessToken, + onRestored, +}: { + skill: VersionedSkill; + version: PublishedVersion; + accessToken: string; + onRestored: (skill: Skill) => void; +}) { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const [open, setOpen] = useState(false); + const [newVersion, setNewVersion] = useState(""); + const [restoring, setRestoring] = useState(false); + const [error, setError] = useState(null); + const restore = async () => { + if (restoring) return; + setRestoring(true); + setError(null); + try { + const restored = await apiClient.post( + `/skill-hub/skills/${encodeURIComponent(skill.name)}/versions/${encodeURIComponent(version.id)}/restore`, + { + body: { + expected_skill_id: skill.id, + expected_updated_at: skill.updated_at ?? null, + version: newVersion.trim() || null, + } satisfies components["schemas"]["SkillHubRestore"], + accessToken, + }, + ); + setOpen(false); + onRestored(restored); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["skill-hub", accessToken], refetchType: "all" }), + queryClient.invalidateQueries({ queryKey: ["skill-versions", skill.id], refetchType: "all" }), + ]); + } catch (cause) { + setError(cause instanceof ApiError ? cause.message : t("claudeCodePlugins.skillDetail.history.restoreFailed")); + } finally { + setRestoring(false); + } + }; + return ( + <> + + !restoring && setOpen(next)}> + + + + {t("claudeCodePlugins.skillDetail.history.restoreTitle", { version: version.version })} + + {t("claudeCodePlugins.skillDetail.history.restoreDescription")} + +
{ + event.preventDefault(); + void restore(); + }} + > + +

{t("claudeCodePlugins.skillDetail.history.versionHint")}

+ {error &&

{error}

} + +
+
+
+ + ); +} + +export default function SkillVersionHistory({ + skill, + accessToken, + isAdmin = false, + onRestored, +}: { + skill: VersionedSkill; + accessToken: string; + isAdmin?: boolean; + onRestored?: (skill: Skill) => void; +}) { const { t } = useTranslation(); const [offset, setOffset] = useState(0); + const onAdminRestore = isAdmin ? onRestored : undefined; const history = useQuery({ queryKey: ["skill-versions", skill.id, skill.updated_at, accessToken, offset], queryFn: () => @@ -84,7 +184,18 @@ export default function SkillVersionHistory({ skill, accessToken }: { skill: Ver )}

{version.content_kind === "stored_archive" && ( - +
+ + {onAdminRestore && version.version !== skill.version && ( + + )} +
)}
{t("claudeCodePlugins.skillDetail.history.metadata")} diff --git a/ui/litellm-dashboard/src/components/claude_code_plugins/skill_detail.tsx b/ui/litellm-dashboard/src/components/claude_code_plugins/skill_detail.tsx index c519816f65f..3128ae9c0a7 100644 --- a/ui/litellm-dashboard/src/components/claude_code_plugins/skill_detail.tsx +++ b/ui/litellm-dashboard/src/components/claude_code_plugins/skill_detail.tsx @@ -123,9 +123,17 @@ interface SkillDetailProps { accessToken?: string | null; onPublishClick?: () => void; onEditClick?: () => void; + onRestored?: (skill: SkillHubSkill) => void; } -const SkillDetail: React.FC = ({ skill, onBack, accessToken, onEditClick, isAdmin = false }) => { +const SkillDetail: React.FC = ({ + skill, + onBack, + accessToken, + onEditClick, + onRestored, + isAdmin = false, +}) => { const { t } = useTranslation(); const [activeTab, setActiveTab] = useState("overview"); const [copiedKey, setCopiedKey] = useState(null); @@ -256,7 +264,13 @@ const SkillDetail: React.FC = ({ skill, onBack, accessToken, o {/* Overview tab */} {visibleTab === "history" && accessToken && ( - + )} {visibleTab === "overview" && ( diff --git a/ui/litellm-dashboard/src/i18n/locales/en.json b/ui/litellm-dashboard/src/i18n/locales/en.json index 626b5f64350..9641cff8a44 100644 --- a/ui/litellm-dashboard/src/i18n/locales/en.json +++ b/ui/litellm-dashboard/src/i18n/locales/en.json @@ -1987,7 +1987,15 @@ "download": "Download retained ZIP", "downloadFailed": "Could not download this version.", "newer": "Newer", - "older": "Older" + "older": "Older", + "restore": "Restore this version", + "restoreTitle": "Restore version {{version}}", + "restoreDescription": "Publish this version's files and metadata as a new release. Existing versions, access permissions, and public or private status are preserved.", + "newVersion": "New version (optional)", + "versionHint": "Leave blank to increment the current patch version, or enter a higher semantic version. Legacy versions require an explicit version.", + "confirmRestore": "Restore as new version", + "restoring": "Restoring…", + "restoreFailed": "Could not restore this version. Reload the skill and try again." }, "back": "Skills Hub", "tabs": { diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 0825dbbd473..505f29e53ff 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -16080,6 +16080,26 @@ export interface paths { patch?: never; trace?: never; }; + "/skill-hub/skills/{name}/versions/{version_id}/restore": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Restore Skill Version + * @description Proxy admins may republish retained bytes and metadata as a new version, preserving visibility and grants. + */ + post: operations["restore_skill_version_skill_hub_skills__name__versions__version_id__restore_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/skill-hub/skills/{plugin_name}/downloads": { parameters: { query?: never; @@ -47571,6 +47591,24 @@ export interface components { /** Reason */ reason?: string | null; }; + /** SkillHubRestore */ + SkillHubRestore: { + /** + * Expected Skill Id + * @description Current skill ID read by the editor + */ + expected_skill_id: string; + /** + * Expected Updated At + * @description Current updated_at read by the editor, including null + */ + expected_updated_at: string | null; + /** + * Version + * @description Higher semantic version, or omit/null for an automatic patch bump + */ + version?: string | null; + }; /** SkillHubReviewResponse */ SkillHubReviewResponse: { /** @@ -75916,6 +75954,42 @@ export interface operations { }; }; }; + restore_skill_version_skill_hub_skills__name__versions__version_id__restore_post: { + parameters: { + query?: never; + header?: never; + path: { + name: string; + version_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SkillHubRestore"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SkillHubSkill"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; get_plugin_downloads_skill_hub_skills__plugin_name__downloads_get: { parameters: { query?: {