mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-12 08:42:18 +00:00
feat(skills): add admin-only version restore (#51309)
* feat(skills): add admin-only version restore * fix(skills): retain verified scan evidence when restoring
This commit is contained in:
parent
baf5718b47
commit
45a31852a3
12 changed files with 611 additions and 7 deletions
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -99,6 +99,10 @@ const SkillHubDashboard: React.FC<SkillHubDashboardProps> = ({
|
|||
isAdmin={isAdmin}
|
||||
accessToken={accessToken}
|
||||
onPublishClick={onPublishSuccess}
|
||||
onRestored={(restored) => {
|
||||
setSelectedSkill({ ...selectedSkill, ...restored, enabled: restored.visibility === "public" });
|
||||
onPublishSuccess?.();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<SkillVersionHistory skill={currentSkill} accessToken="test-token" isAdmin={isAdmin} onRestored={vi.fn()} />,
|
||||
);
|
||||
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(
|
||||
<SkillVersionHistory skill={currentSkill} accessToken="test-token" isAdmin onRestored={onRestored} />,
|
||||
);
|
||||
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(
|
||||
<SkillVersionHistory skill={currentSkill} accessToken="test-token" isAdmin onRestored={onRestored} />,
|
||||
);
|
||||
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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<Skill, "id" | "name" | "version" | "updated_at">;
|
||||
|
||||
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<string | null>(null);
|
||||
const restore = async () => {
|
||||
if (restoring) return;
|
||||
setRestoring(true);
|
||||
setError(null);
|
||||
try {
|
||||
const restored = await apiClient.post<Skill>(
|
||||
`/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 (
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
|
||||
{t("claudeCodePlugins.skillDetail.history.restore")}
|
||||
</Button>
|
||||
<Dialog open={open} onOpenChange={(next) => !restoring && setOpen(next)}>
|
||||
<DialogContent showCloseButton={!restoring}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("claudeCodePlugins.skillDetail.history.restoreTitle", { version: version.version })}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{t("claudeCodePlugins.skillDetail.history.restoreDescription")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void restore();
|
||||
}}
|
||||
>
|
||||
<label className="block space-y-2">
|
||||
<span>{t("claudeCodePlugins.skillDetail.history.newVersion")}</span>
|
||||
<Input value={newVersion} onChange={(event) => setNewVersion(event.target.value)} disabled={restoring} />
|
||||
</label>
|
||||
<p className="text-sm text-muted-foreground">{t("claudeCodePlugins.skillDetail.history.versionHint")}</p>
|
||||
{error && <p role="alert">{error}</p>}
|
||||
<Button type="submit" disabled={restoring}>
|
||||
{t(
|
||||
restoring
|
||||
? "claudeCodePlugins.skillDetail.history.restoring"
|
||||
: "claudeCodePlugins.skillDetail.history.confirmRestore",
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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
|
|||
)}
|
||||
</p>
|
||||
{version.content_kind === "stored_archive" && (
|
||||
<VersionArchive skillName={skill.name} version={version} accessToken={accessToken} />
|
||||
<div className="flex gap-2">
|
||||
<VersionArchive skillName={skill.name} version={version} accessToken={accessToken} />
|
||||
{onAdminRestore && version.version !== skill.version && (
|
||||
<RestoreVersion
|
||||
key={`${skill.id}:${skill.updated_at}:${accessToken}`}
|
||||
skill={skill}
|
||||
version={version}
|
||||
accessToken={accessToken}
|
||||
onRestored={onAdminRestore}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<details className="mt-3 text-sm">
|
||||
<summary className="cursor-pointer">{t("claudeCodePlugins.skillDetail.history.metadata")}</summary>
|
||||
|
|
|
|||
|
|
@ -123,9 +123,17 @@ interface SkillDetailProps {
|
|||
accessToken?: string | null;
|
||||
onPublishClick?: () => void;
|
||||
onEditClick?: () => void;
|
||||
onRestored?: (skill: SkillHubSkill) => void;
|
||||
}
|
||||
|
||||
const SkillDetail: React.FC<SkillDetailProps> = ({ skill, onBack, accessToken, onEditClick, isAdmin = false }) => {
|
||||
const SkillDetail: React.FC<SkillDetailProps> = ({
|
||||
skill,
|
||||
onBack,
|
||||
accessToken,
|
||||
onEditClick,
|
||||
onRestored,
|
||||
isAdmin = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState("overview");
|
||||
const [copiedKey, setCopiedKey] = useState<string | null>(null);
|
||||
|
|
@ -256,7 +264,13 @@ const SkillDetail: React.FC<SkillDetailProps> = ({ skill, onBack, accessToken, o
|
|||
{/* Overview tab */}
|
||||
{visibleTab === "history" && accessToken && (
|
||||
<TabsContent value="history">
|
||||
<SkillVersionHistory key={`${skill.id}:${accessToken}`} skill={skill} accessToken={accessToken} />
|
||||
<SkillVersionHistory
|
||||
key={`${skill.id}:${accessToken}:${isAdmin}`}
|
||||
skill={skill}
|
||||
accessToken={accessToken}
|
||||
isAdmin={isAdmin}
|
||||
onRestored={onRestored}
|
||||
/>
|
||||
</TabsContent>
|
||||
)}
|
||||
{visibleTab === "overview" && (
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
74
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
74
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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?: {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue