mirror of
https://github.com/tiennm99/litellm.git
synced 2026-08-02 10:21:52 +00:00
* 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>
250 lines
7.9 KiB
TypeScript
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;
|