litellm/ui/litellm-dashboard/src/components/claude_code_plugins.tsx
ishaan-berri 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

146 lines
4.5 KiB
TypeScript

import React, { useState, useEffect } from "react";
import { Button } from "@tremor/react";
import { Modal } from "antd";
import {
getClaudeCodePluginsList,
deleteClaudeCodePlugin,
} from "./networking";
import AddPluginForm from "./claude_code_plugins/add_plugin_form";
import PluginTable from "./claude_code_plugins/plugin_table";
import SkillDetail from "./claude_code_plugins/skill_detail";
import { isAdminRole } from "@/utils/roles";
import NotificationsManager from "./molecules/notifications_manager";
import { Plugin, ListPluginsResponse } from "./claude_code_plugins/types";
interface ClaudeCodePluginsPanelProps {
accessToken: string | null;
userRole?: string;
}
const ClaudeCodePluginsPanel: React.FC<ClaudeCodePluginsPanelProps> = ({
accessToken,
userRole,
}) => {
const [pluginsList, setPluginsList] = useState<Plugin[]>([]);
const [isAddModalVisible, setIsAddModalVisible] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [pluginToDelete, setPluginToDelete] = useState<{
name: string;
displayName: string;
} | null>(null);
const [selectedSkill, setSelectedSkill] = useState<Plugin | null>(null);
const isAdmin = userRole ? isAdminRole(userRole) : false;
const fetchPlugins = async () => {
if (!accessToken) return;
setIsLoading(true);
try {
const response: ListPluginsResponse = await getClaudeCodePluginsList(
accessToken,
false
);
setPluginsList(response.plugins);
} catch (error) {
console.error("Error fetching skills:", error);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchPlugins();
}, [accessToken]);
const handleDeleteClick = (pluginName: string, displayName: string) => {
setPluginToDelete({ name: pluginName, displayName });
};
const handleDeleteConfirm = async () => {
if (!pluginToDelete || !accessToken) return;
setIsDeleting(true);
try {
await deleteClaudeCodePlugin(accessToken, pluginToDelete.name);
NotificationsManager.success(`Skill "${pluginToDelete.displayName}" deleted successfully`);
fetchPlugins();
} catch (error) {
console.error("Error deleting skill:", error);
NotificationsManager.error("Failed to delete skill");
} finally {
setIsDeleting(false);
setPluginToDelete(null);
}
};
return (
<div className="w-full mx-auto flex-auto overflow-y-auto m-8 p-2">
{selectedSkill ? (
<SkillDetail
skill={selectedSkill}
onBack={() => setSelectedSkill(null)}
isAdmin={isAdmin}
accessToken={accessToken}
onPublishClick={fetchPlugins}
/>
) : (
<>
<div className="flex flex-col gap-2 mb-4">
<h1 className="text-2xl font-bold">Skills</h1>
<p className="text-sm text-gray-600">
Register Claude Code skills. Published skills appear in the Skill Hub for all users and
are served via{" "}
<code className="bg-gray-100 px-1 rounded">/claude-code/marketplace.json</code>.
</p>
<div className="mt-2 flex gap-2">
<Button onClick={() => setIsAddModalVisible(true)} disabled={!accessToken || !isAdmin}>
+ Add Skill
</Button>
</div>
</div>
<PluginTable
pluginsList={pluginsList}
isLoading={isLoading}
onDeleteClick={handleDeleteClick}
accessToken={accessToken}
isAdmin={isAdmin}
onPluginClick={(id) => {
const skill = pluginsList.find((p) => p.id === id);
if (skill) setSelectedSkill(skill);
}}
/>
</>
)}
<AddPluginForm
visible={isAddModalVisible}
onClose={() => setIsAddModalVisible(false)}
accessToken={accessToken}
onSuccess={fetchPlugins}
/>
{pluginToDelete && (
<Modal
title="Delete Skill"
open={pluginToDelete !== null}
onOk={handleDeleteConfirm}
onCancel={() => setPluginToDelete(null)}
confirmLoading={isDeleting}
okText="Delete"
okButtonProps={{ danger: true }}
>
<p>
Are you sure you want to delete skill:{" "}
<strong>{pluginToDelete.displayName}</strong>?
</p>
<p>This action cannot be undone.</p>
</Modal>
)}
</div>
);
};
export default ClaudeCodePluginsPanel;