Files
litellm/ui/litellm-dashboard/src/components/claude_code_plugins/MakeSkillPublicForm.tsx
T
0afffe4366 feat: multiple concurrent budget windows per API key and team (#24883) (#25109)
* feat: multiple concurrent budget windows per API key and team (#24883)

* feat(proxy): add BudgetLimitEntry type and wire budget_limits into key/team models

* feat(schema): add budget_limits Json column to VerificationToken and TeamTable

* feat(migrations): add migration for budget_limits column on keys and teams

* feat(keys): initialize budget_limits windows with reset_at on key create/update

* feat(teams): initialize budget_limits windows with reset_at on team create/update

* feat(auth): add _virtual_key_multi_budget_check and _team_multi_budget_check

* feat(auth): call multi-budget checks from common_checks for keys and teams

* feat(proxy): increment per-window Redis spend counters after each request

* feat(budget): reset individual budget windows on schedule via reset_budget_job

* feat(ui): add hourly option to BudgetDurationDropdown

* feat(ui): add budget_limits field to KeyResponse type

* feat(ui): add Budget Windows editor to key edit view

* feat(ui): add Budget Windows editor to create key form

* fix(proxy): strip budget_limits=None before Prisma upsert to fix login 500

Prisma rejects nullable JSON fields (Json? without @default) when passed as
Python None — it needs the field omitted entirely so the DB stores NULL via
the column's nullable constraint. This was breaking /v2/login because the UI
session key creation path hit the upsert with budget_limits=None.

* ui(key-edit): use antd InputNumber+Button for budget windows, add reset hints

* ui(create-key): use antd InputNumber+Button for budget windows, add reset hints

* docs(users): add multiple budget windows section with API + dashboard walkthrough

* fix: BudgetExceededError returns HTTP 429 instead of 400

- Add status_code=429 to BudgetExceededError class
- auth_exception_handler hardcoded code=400 → code=429

* fix: no-op else branch in multi-budget auth checks causes KeyError

- BudgetLimitEntry objects must be coerced via model_dump() not left as-is
- Move _virtual_key_multi_budget_check into common_checks (was asymmetric
  with _team_multi_budget_check which already lived there)

* fix: len() on JSON string returns char count not window count

Guard with isinstance check + json.loads() before iterating per-window
Redis counters in increment_spend_counters

* fix: silent except:pass hides Redis reset failures in reset_budget_windows

Log Redis counter reset failures as warnings so they are observable

* test: add unit tests for multi-budget window enforcement

5 tests covering: no budget_limits passes, under budget passes,
over hourly window raises 429, over monthly window raises 429,
BudgetLimitEntry objects coerced without KeyError

* fix: key per-window counters stable across reorders (duration key, not index)

* fix: team+key per-window spend increments use duration key, not index

* fix: budget window reset uses duration key; log failures instead of swallowing

* refactor: extract BudgetWindowsEditor to shared component

* refactor: key_edit_view imports BudgetWindowsEditor from shared component

* refactor: create_key_button imports BudgetWindowsEditor from shared component

---------

Co-authored-by: Ishaan Jaffer <ishaanjaffer0324@gmail.com>

* fix(reset_budget_job): extract _reset_expired_window helper to fix PLR0915 too many statements

* feat(skills): Skills Registry & Hub — register skills, browse in AI Hub, public skill hub (#25118)

* feat(skills): add domain and namespace fields to plugin types

* feat(skills): store and return domain/namespace inside manifest_json

* feat(skills): add /public/skill_hub endpoint for unauthenticated access

* feat(skills): whitelist /public/skill_hub from auth requirements

* feat(skills): add domain, namespace to Plugin and RegisterPluginRequest types

* feat(skills): smart URL parser — paste github URL, auto-detect source type and name

* feat(skills): replace enable toggle with Public badge, make rows clickable

* feat(skills): add skill detail view with Overview and How to Use tabs

* feat(skills): add MakeSkillPublicForm modal for publishing skills to the hub

* feat(skills): rename panel to Skills, wire in skill detail view on row click

* feat(skills): add skill hub table columns — name, description, domain, source, status

* feat(skills): add SkillHubDashboard with stats row, domain dropdown filter, and table

* feat(skills): add Skill Hub tab to AI Hub with Select Skills to Make Public button

* feat(skills): move Skills to top-level nav item directly under MCP Servers

* feat(skills): add skillHubPublicCall and NEXT_PUBLIC_BASE_URL support

* feat(skills): add Skill Hub tab to public AI Hub page

* feat(skills): add skills page routing in main app router

* feat(skills): add /skills page route

* chore: update package-lock after npm install

* docs(skills): add Skills Gateway doc page with mermaid architecture diagram

* docs(skills): add Skills Gateway to sidebar under Agent & MCP Gateway

* docs(skills): add loom walkthrough video to Skills Gateway doc

* chore: fixes

---------

Co-authored-by: Ishaan Jaffer <ishaanjaffer0324@gmail.com>
Co-authored-by: Yuneng Jiang <yuneng@berri.ai>
2026-04-06 14:02:04 -07:00

250 lines
7.9 KiB
TypeScript

import React, { useState, useEffect } from "react";
import { Modal, Form, Steps, Button, Checkbox } from "antd";
import { Text, Title, Badge } from "@tremor/react";
import { enableClaudeCodePlugin, disableClaudeCodePlugin } from "../networking";
import NotificationsManager from "../molecules/notifications_manager";
import { Plugin } from "./types";
const { Step } = Steps;
interface MakeSkillPublicFormProps {
visible: boolean;
onClose: () => void;
accessToken: string;
skillsList: Plugin[];
onSuccess: () => void;
}
const MakeSkillPublicForm: React.FC<MakeSkillPublicFormProps> = ({
visible,
onClose,
accessToken,
skillsList,
onSuccess,
}) => {
const [currentStep, setCurrentStep] = useState(0);
const [selectedSkills, setSelectedSkills] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(false);
const [form] = Form.useForm();
const handleClose = () => {
setCurrentStep(0);
setSelectedSkills(new Set());
form.resetFields();
onClose();
};
const handleNext = () => {
if (selectedSkills.size === 0) {
NotificationsManager.fromBackend("Please select at least one skill");
return;
}
setCurrentStep(1);
};
const handleSkillSelection = (name: string, checked: boolean) => {
const next = new Set(selectedSkills);
if (checked) {
next.add(name);
} else {
next.delete(name);
}
setSelectedSkills(next);
};
const handleSelectAll = (checked: boolean) => {
if (checked) {
setSelectedSkills(new Set(skillsList.map((s) => s.name)));
} else {
setSelectedSkills(new Set());
}
};
// Pre-check already-published skills when modal opens
useEffect(() => {
if (visible && skillsList.length > 0) {
setSelectedSkills(new Set(skillsList.filter((s) => s.enabled).map((s) => s.name)));
}
}, [visible, skillsList]);
const handleSubmit = async () => {
if (selectedSkills.size === 0) {
NotificationsManager.fromBackend("Please select at least one skill");
return;
}
setLoading(true);
try {
const selectedSet = selectedSkills;
await Promise.all(
skillsList.map((skill) => {
const shouldBePublic = selectedSet.has(skill.name);
if (shouldBePublic && !skill.enabled) {
return enableClaudeCodePlugin(accessToken, skill.name);
}
if (!shouldBePublic && skill.enabled) {
return disableClaudeCodePlugin(accessToken, skill.name);
}
return Promise.resolve();
})
);
NotificationsManager.success(`Skill Hub updated — ${selectedSkills.size} skill(s) published`);
handleClose();
onSuccess();
} catch (error) {
console.error("Error publishing skills:", error);
NotificationsManager.fromBackend("Failed to update skills. Please try again.");
} finally {
setLoading(false);
}
};
const allSelected =
skillsList.length > 0 && skillsList.every((s) => selectedSkills.has(s.name));
const isIndeterminate = selectedSkills.size > 0 && !allSelected;
const renderStep1 = () => (
<div className="space-y-4">
<div className="flex items-center justify-between">
<Title>Select Skills to Publish</Title>
<Checkbox
checked={allSelected}
indeterminate={isIndeterminate}
onChange={(e) => handleSelectAll(e.target.checked)}
disabled={skillsList.length === 0}
>
Select All ({skillsList.length})
</Checkbox>
</div>
<Text className="text-sm text-gray-600">
Selected skills will be visible to all users in the Skill Hub.
Deselected skills will be unpublished.
</Text>
<div className="max-h-96 overflow-y-auto border rounded-lg p-4">
<div className="space-y-3">
{skillsList.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<Text>No skills registered yet.</Text>
</div>
) : (
skillsList.map((skill) => (
<div
key={skill.name}
className="flex items-center space-x-3 p-3 border rounded-lg hover:bg-gray-50"
>
<Checkbox
checked={selectedSkills.has(skill.name)}
onChange={(e) => handleSkillSelection(skill.name, e.target.checked)}
/>
<div className="flex-1">
<div className="flex items-center gap-2">
<Text className="font-medium font-mono text-sm">{skill.name}</Text>
{skill.enabled && (
<Badge color="green" size="xs">Public</Badge>
)}
</div>
{skill.description && (
<Text className="text-xs text-gray-500 truncate max-w-sm">
{skill.description}
</Text>
)}
</div>
{skill.domain && (
<Badge color="blue" size="xs">{skill.domain}</Badge>
)}
</div>
))
)}
</div>
</div>
{selectedSkills.size > 0 && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
<Text className="text-sm text-blue-800">
<strong>{selectedSkills.size}</strong> skill{selectedSkills.size !== 1 ? "s" : ""} will be published
</Text>
</div>
)}
</div>
);
const renderStep2 = () => (
<div className="space-y-4">
<Title>Confirm Publish to Skill Hub</Title>
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<Text className="text-sm text-yellow-800">
<strong>Note:</strong> Published skills will be visible to all users in the Skill Hub tab.
Skills not in the list below will be unpublished.
</Text>
</div>
<div className="space-y-3">
<Text className="font-medium">Skills to be published:</Text>
<div className="max-h-48 overflow-y-auto border rounded-lg p-3">
<div className="space-y-2">
{Array.from(selectedSkills).map((name) => {
const skill = skillsList.find((s) => s.name === name);
return (
<div key={name} className="flex items-center justify-between p-2 bg-gray-50 rounded">
<Text className="font-mono text-sm">{name}</Text>
{skill?.domain && <Badge color="blue" size="xs">{skill.domain}</Badge>}
</div>
);
})}
</div>
</div>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
<Text className="text-sm text-blue-800">
Total: <strong>{selectedSkills.size}</strong> skill{selectedSkills.size !== 1 ? "s" : ""} will be published
</Text>
</div>
</div>
);
return (
<Modal
title="Publish to Skill Hub"
open={visible}
onCancel={handleClose}
footer={null}
width={700}
maskClosable={false}
>
<Form form={form} layout="vertical">
<Steps current={currentStep} className="mb-6">
<Step title="Select Skills" />
<Step title="Confirm" />
</Steps>
{currentStep === 0 ? renderStep1() : renderStep2()}
<div className="flex justify-between mt-6">
<Button onClick={currentStep === 0 ? handleClose : () => setCurrentStep(0)}>
{currentStep === 0 ? "Cancel" : "Previous"}
</Button>
<div className="flex space-x-2">
{currentStep === 0 && (
<Button onClick={handleNext} disabled={selectedSkills.size === 0}>
Next
</Button>
)}
{currentStep === 1 && (
<Button onClick={handleSubmit} loading={loading}>
Publish to Hub
</Button>
)}
</div>
</div>
</Form>
</Modal>
);
};
export default MakeSkillPublicForm;