mirror of
https://github.com/tiennm99/litellm.git
synced 2026-08-02 08:21:53 +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>
This commit is contained in:
co-authored by
Ishaan Jaffer
Yuneng Jiang
parent
414d3966bf
commit
0afffe4366
@@ -333,6 +333,67 @@ curl 'http://0.0.0.0:4000/key/generate' \
|
||||
}'
|
||||
```
|
||||
|
||||
#### **Set multiple budget windows on a key**
|
||||
|
||||
Apply multiple concurrent budget limits at different time scales on the same key — for example, cap a key at **$10/day** AND **$100/month**.
|
||||
|
||||
**When is this useful?**
|
||||
|
||||
A single `budget_duration` window can't prevent a bad day from burning your entire month. Multiple budget windows let you:
|
||||
|
||||
- Block a runaway usage spike within the day while still allowing normal monthly spend.
|
||||
- Give Claude Code rollouts a daily guardrail (`24h`) and a monthly ceiling (`30d`) so a single heavy session doesn't exhaust the whole month.
|
||||
- Layer fine-grained hourly limits for bursty workloads on top of a weekly cap.
|
||||
|
||||
:::info
|
||||
|
||||
See [User Budget docs](https://docs.litellm.ai/docs/proxy/users) for more on how budgets work across keys, teams, and users.
|
||||
|
||||
:::
|
||||
|
||||
**Via API**
|
||||
|
||||
Pass `budget_limits` as a list of `{budget_duration, max_budget}` objects:
|
||||
|
||||
```bash
|
||||
curl 'http://0.0.0.0:4000/key/generate' \
|
||||
--header 'Authorization: Bearer <your-master-key>' \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data-raw '{
|
||||
"budget_limits": [
|
||||
{"budget_duration": "24h", "max_budget": 10},
|
||||
{"budget_duration": "30d", "max_budget": 100}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
Each window is tracked independently and resets on its own schedule:
|
||||
|
||||
| `budget_duration` | Resets |
|
||||
|---|---|
|
||||
| `1h` | Every hour |
|
||||
| `24h` | Daily at midnight UTC |
|
||||
| `7d` | Every Sunday at midnight UTC |
|
||||
| `30d` | 1st of every month at midnight UTC |
|
||||
|
||||
**Via Dashboard**
|
||||
|
||||
Open **Virtual Keys → Create Key → Optional Settings → Budget Windows**.
|
||||
|
||||

|
||||
|
||||
Click **+ Add Budget Window** to add a row, choose the period from the dropdown, and enter the spend cap.
|
||||
|
||||

|
||||
|
||||
Add a second row for a different time period (e.g. monthly $100 on top of a daily $10).
|
||||
|
||||

|
||||
|
||||
Each window shows the reset schedule below the input so it's always clear when spend resets.
|
||||
|
||||

|
||||
|
||||
|
||||
### ✨ Virtual Key (Model Specific)
|
||||
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
# Skills Gateway
|
||||
|
||||
<iframe width="840" height="500" src="https://www.loom.com/embed/cb74eb79df3e4c2b83a6efae54a589f9" frameborder="0" webkitallowfullscreen mozallowfullscreen allowfullscreen></iframe>
|
||||
|
||||
LiteLLM acts as a **Skills Registry** — a central place to register, manage, and discover Claude Code skills across your organization. Teams can publish skills once and have agents and developers find them through a single hub.
|
||||
|
||||
## How it works
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
Dev["👨💻 Developer<br/>registers a skill<br/>(GitHub URL or subdir)"] -->|POST /claude-code/plugins| Proxy["LiteLLM Proxy<br/>(Skills Registry)"]
|
||||
|
||||
Admin["🔑 Admin<br/>publishes skill<br/>(marks as public)"] -->|enable via UI or API| Proxy
|
||||
|
||||
Proxy -->|GET /public/skill_hub| SkillHub["🗂️ Skill Hub<br/>(AI Hub → Skill Hub tab)"]
|
||||
Proxy -->|GET /claude-code/marketplace.json| Marketplace["📦 Claude Code<br/>Marketplace endpoint"]
|
||||
|
||||
SkillHub --> Human["🧑 Human<br/>browses & discovers skills<br/>in AI Hub UI"]
|
||||
Marketplace --> Agent["🤖 Agent / Claude Code<br/>installs skill with<br/>/plugin marketplace add <name>"]
|
||||
|
||||
style Proxy fill:#1a73e8,color:#fff
|
||||
style SkillHub fill:#e8f0fe,color:#1a73e8
|
||||
style Marketplace fill:#e8f0fe,color:#1a73e8
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
### 1. Register a skill
|
||||
|
||||
Paste any GitHub URL into the Skills UI — LiteLLM auto-detects the source type and skill name.
|
||||
|
||||
```bash
|
||||
curl -X POST https://your-proxy/claude-code/plugins \
|
||||
-H "Authorization: Bearer $LITELLM_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "grill-me",
|
||||
"source": {
|
||||
"source": "git-subdir",
|
||||
"url": "https://github.com/mattpocock/skills",
|
||||
"path": "grill-me"
|
||||
},
|
||||
"description": "Interview skill for relentless questioning",
|
||||
"domain": "Productivity",
|
||||
"namespace": "interviews"
|
||||
}'
|
||||
```
|
||||
|
||||
Skills nested in subdirectories (e.g. `github.com/org/repo/tree/main/skill-name`) are supported — LiteLLM parses the URL automatically in the UI.
|
||||
|
||||
### 2. Publish to hub
|
||||
|
||||
In the Admin UI: **AI Hub → Skill Hub → Select Skills to Make Public**.
|
||||
|
||||
Or via API:
|
||||
|
||||
```bash
|
||||
curl -X POST https://your-proxy/claude-code/plugins/grill-me/enable \
|
||||
-H "Authorization: Bearer $LITELLM_KEY"
|
||||
```
|
||||
|
||||
### 3. Browse the hub
|
||||
|
||||
Public skills appear at:
|
||||
- **Admin UI**: AI Hub → Skill Hub tab
|
||||
- **Public page**: `/ui/model_hub` → Skill Hub tab (no login required)
|
||||
- **API**: `GET /public/skill_hub`
|
||||
|
||||
### 4. Install in Claude Code
|
||||
|
||||
Point Claude Code at your proxy marketplace once:
|
||||
|
||||
```json title="~/.claude/settings.json"
|
||||
{
|
||||
"extraKnownMarketplaces": {
|
||||
"my-org": {
|
||||
"source": "url",
|
||||
"url": "https://your-proxy/claude-code/marketplace.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Then install any skill:
|
||||
|
||||
```
|
||||
/plugin marketplace add grill-me
|
||||
```
|
||||
|
||||
## Skill fields
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| `name` | Unique skill identifier (used in `/plugin marketplace add`) |
|
||||
| `source` | Git source — `github`, `url`, or `git-subdir` |
|
||||
| `description` | Short description shown in the hub |
|
||||
| `domain` | Category for grouping (e.g. `Engineering`, `Productivity`) |
|
||||
| `namespace` | Subcategory within a domain (e.g. `quality`, `meetings`) |
|
||||
| `keywords` | Tags for search and filtering |
|
||||
| `version` | Semver string |
|
||||
|
||||
## API reference
|
||||
|
||||
| Endpoint | Auth | Description |
|
||||
|----------|------|-------------|
|
||||
| `POST /claude-code/plugins` | Required | Register a skill |
|
||||
| `GET /claude-code/plugins` | Required | List all skills (admin) |
|
||||
| `POST /claude-code/plugins/{name}/enable` | Required | Publish a skill |
|
||||
| `POST /claude-code/plugins/{name}/disable` | Required | Unpublish a skill |
|
||||
| `GET /public/skill_hub` | None | List public skills |
|
||||
| `GET /claude-code/marketplace.json` | None | Claude Code marketplace manifest |
|
||||
@@ -333,6 +333,13 @@ const sidebars = {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "category",
|
||||
label: "Skills Gateway",
|
||||
items: [
|
||||
"skills_gateway",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
-- AlterTable: add budget_limits column to LiteLLM_VerificationToken
|
||||
ALTER TABLE "LiteLLM_VerificationToken" ADD COLUMN IF NOT EXISTS "budget_limits" JSONB;
|
||||
|
||||
-- AlterTable: add budget_limits column to LiteLLM_TeamTable
|
||||
ALTER TABLE "LiteLLM_TeamTable" ADD COLUMN IF NOT EXISTS "budget_limits" JSONB;
|
||||
@@ -141,6 +141,7 @@ model LiteLLM_TeamTable {
|
||||
team_member_permissions String[] @default([])
|
||||
access_group_ids String[] @default([])
|
||||
policies String[] @default([])
|
||||
budget_limits Json? // multiple concurrent budget windows [{budget_duration, max_budget, reset_at}]
|
||||
default_team_member_models String[] @default([]) // default allowed_models for newly added team members; empty = no per-member restriction
|
||||
model_id Int? @unique // id for LiteLLM_ModelTable -> stores team-level model aliases
|
||||
allow_team_guardrail_config Boolean @default(false) // if true, team admin can configure guardrails for this team
|
||||
@@ -402,6 +403,7 @@ model LiteLLM_VerificationToken {
|
||||
rotation_interval String? // How often to rotate (e.g., "30d", "90d")
|
||||
last_rotation_at DateTime? // When this key was last rotated
|
||||
key_rotation_at DateTime? // When this key should next be rotated
|
||||
budget_limits Json? // multiple concurrent budget windows [{budget_duration, max_budget, reset_at}]
|
||||
litellm_budget_table LiteLLM_BudgetTable? @relation(fields: [budget_id], references: [budget_id])
|
||||
litellm_organization_table LiteLLM_OrganizationTable? @relation(fields: [organization_id], references: [organization_id])
|
||||
litellm_project_table LiteLLM_ProjectTable? @relation(fields: [project_id], references: [project_id])
|
||||
|
||||
@@ -281,7 +281,7 @@ class Timeout(openai.APITimeoutError): # type: ignore
|
||||
return _message
|
||||
|
||||
|
||||
class PermissionDeniedError(openai.PermissionDeniedError): # type:ignore
|
||||
class PermissionDeniedError(openai.PermissionDeniedError): # type: ignore
|
||||
def __init__(
|
||||
self,
|
||||
message,
|
||||
@@ -847,6 +847,7 @@ class BudgetExceededError(Exception):
|
||||
):
|
||||
self.current_cost = current_cost
|
||||
self.max_budget = max_budget
|
||||
self.status_code = 429
|
||||
message = (
|
||||
message
|
||||
or f"Budget has been exceeded! Current cost: {current_cost}, Max budget: {max_budget}"
|
||||
|
||||
@@ -596,6 +596,7 @@ class LiteLLMRoutes(enum.Enum):
|
||||
"/public/model_hub",
|
||||
"/public/agent_hub",
|
||||
"/public/mcp_hub",
|
||||
"/public/skill_hub",
|
||||
"/public/litellm_model_cost_map",
|
||||
]
|
||||
)
|
||||
@@ -868,6 +869,14 @@ class LiteLLM_ObjectPermissionBase(LiteLLMPydanticObjectBase):
|
||||
models: Optional[List[str]] = None
|
||||
|
||||
|
||||
class BudgetLimitEntry(LiteLLMPydanticObjectBase):
|
||||
"""A single budget window with its own limit and independent reset schedule."""
|
||||
|
||||
budget_duration: str # e.g. "24h", "7d", "30d"
|
||||
max_budget: float # max spend in USD for this window
|
||||
reset_at: Optional[datetime] = None # populated at creation/reset time
|
||||
|
||||
|
||||
class GenerateRequestBase(LiteLLMPydanticObjectBase):
|
||||
"""
|
||||
Overlapping schema between key and user generate/update requests
|
||||
@@ -887,6 +896,9 @@ class GenerateRequestBase(LiteLLMPydanticObjectBase):
|
||||
rpm_limit: Optional[int] = None
|
||||
|
||||
budget_duration: Optional[str] = None
|
||||
budget_limits: Optional[List[BudgetLimitEntry]] = (
|
||||
None # multiple concurrent budget windows
|
||||
)
|
||||
allowed_cache_controls: Optional[list] = []
|
||||
config: Optional[dict] = {}
|
||||
permissions: Optional[dict] = {}
|
||||
@@ -991,6 +1003,7 @@ class GenerateKeyResponse(KeyRequestBase):
|
||||
"permissions",
|
||||
"model_max_budget",
|
||||
"router_settings",
|
||||
"budget_limits",
|
||||
]
|
||||
for field in dict_fields:
|
||||
value = values.get(field)
|
||||
@@ -1644,6 +1657,9 @@ class TeamBase(LiteLLMPydanticObjectBase):
|
||||
max_budget: Optional[float] = None
|
||||
soft_budget: Optional[float] = None
|
||||
budget_duration: Optional[str] = None
|
||||
budget_limits: Optional[List[BudgetLimitEntry]] = (
|
||||
None # multiple concurrent budget windows
|
||||
)
|
||||
|
||||
models: list = []
|
||||
blocked: bool = False
|
||||
@@ -1745,6 +1761,8 @@ class UpdateTeamRequest(LiteLLMPydanticObjectBase):
|
||||
enforced_file_expires_after: Optional[dict] = None
|
||||
router_settings: Optional[dict] = None
|
||||
access_group_ids: Optional[List[str]] = None
|
||||
budget_limits: Optional[List[BudgetLimitEntry]] = (
|
||||
None # multiple concurrent budget windows
|
||||
default_team_member_models: Optional[List[str]] = (
|
||||
None # default allowed_models seeded onto new team members
|
||||
)
|
||||
@@ -1893,6 +1911,7 @@ class LiteLLM_TeamTable(TeamBase):
|
||||
"model_max_budget",
|
||||
"model_aliases",
|
||||
"router_settings",
|
||||
"budget_limits",
|
||||
]
|
||||
|
||||
if isinstance(values, BaseModel):
|
||||
@@ -2378,6 +2397,7 @@ class LiteLLM_VerificationToken(LiteLLMPydanticObjectBase):
|
||||
last_rotation_at: Optional[datetime] = None # When this key was last rotated
|
||||
key_rotation_at: Optional[datetime] = None # When this key should next be rotated
|
||||
router_settings: Optional[dict] = None
|
||||
budget_limits: Optional[List[dict]] = None # multiple concurrent budget windows
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
|
||||
|
||||
@@ -260,6 +260,10 @@ async def register_plugin(
|
||||
manifest["keywords"] = request.keywords
|
||||
if request.category:
|
||||
manifest["category"] = request.category
|
||||
if request.domain:
|
||||
manifest["domain"] = request.domain
|
||||
if request.namespace:
|
||||
manifest["namespace"] = request.namespace
|
||||
|
||||
# Check if plugin exists
|
||||
existing = await prisma_client.db.litellm_claudecodeplugintable.find_unique(
|
||||
@@ -362,6 +366,8 @@ async def list_plugins(
|
||||
homepage=manifest.get("homepage"),
|
||||
keywords=manifest.get("keywords"),
|
||||
category=manifest.get("category"),
|
||||
domain=manifest.get("domain"),
|
||||
namespace=manifest.get("namespace"),
|
||||
enabled=p.enabled,
|
||||
created_at=p.created_at.isoformat() if p.created_at else None,
|
||||
updated_at=p.updated_at.isoformat() if p.updated_at else None,
|
||||
|
||||
@@ -534,6 +534,17 @@ async def common_checks( # noqa: PLR0915
|
||||
valid_token=valid_token,
|
||||
)
|
||||
|
||||
# 3.1. Multi-window budget check for team
|
||||
with tracer.trace("litellm.proxy.auth.common_checks.team_multi_budget_check"):
|
||||
await _team_multi_budget_check(team_object=team_object)
|
||||
|
||||
# 3.2. Multi-window budget check for key
|
||||
with tracer.trace(
|
||||
"litellm.proxy.auth.common_checks.virtual_key_multi_budget_check"
|
||||
):
|
||||
if valid_token is not None:
|
||||
await _virtual_key_multi_budget_check(valid_token=valid_token)
|
||||
|
||||
# 3.0.5. If team is over soft budget (alert only, doesn't block)
|
||||
with tracer.trace("litellm.proxy.auth.common_checks.team_soft_budget_check"):
|
||||
await _team_soft_budget_check(
|
||||
@@ -2982,6 +2993,43 @@ async def _virtual_key_max_budget_check(
|
||||
)
|
||||
|
||||
|
||||
async def _virtual_key_multi_budget_check(
|
||||
valid_token: UserAPIKeyAuth,
|
||||
):
|
||||
"""
|
||||
Raises BudgetExceededError if any budget window in valid_token.budget_limits is exceeded.
|
||||
|
||||
Each window has its own Redis counter keyed by spend:key:{token}:window:{budget_duration}.
|
||||
Using budget_duration (not list index) keeps counters stable when windows are reordered
|
||||
or removed during a key update.
|
||||
|
||||
Note: counters are not seeded from DB on Redis cold-start. After a Redis flush,
|
||||
per-window spend resets to zero within the current window period. This is an acceptable
|
||||
trade-off: the DB stores reset_at timestamps but not per-window accumulated spend.
|
||||
"""
|
||||
if not valid_token.budget_limits:
|
||||
return
|
||||
|
||||
from litellm.proxy.proxy_server import get_current_spend
|
||||
|
||||
for window in valid_token.budget_limits:
|
||||
w: dict = window if isinstance(window, dict) else window.model_dump()
|
||||
counter_key = f"spend:key:{valid_token.token}:window:{w['budget_duration']}"
|
||||
window_spend = await get_current_spend(
|
||||
counter_key=counter_key,
|
||||
fallback_spend=0.0,
|
||||
)
|
||||
if window_spend >= w["max_budget"]:
|
||||
raise litellm.BudgetExceededError(
|
||||
current_cost=window_spend,
|
||||
max_budget=w["max_budget"],
|
||||
message=(
|
||||
f"ExceededBudget: Key over {w['budget_duration']} budget. "
|
||||
f"Spend=${window_spend:.4f}, Limit=${w['max_budget']:.2f}"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def _virtual_key_soft_budget_check(
|
||||
valid_token: UserAPIKeyAuth,
|
||||
proxy_logging_obj: ProxyLogging,
|
||||
@@ -3223,6 +3271,39 @@ async def _team_max_budget_check(
|
||||
)
|
||||
|
||||
|
||||
async def _team_multi_budget_check(
|
||||
team_object: Optional[LiteLLM_TeamTable],
|
||||
):
|
||||
"""
|
||||
Raises BudgetExceededError if any budget window in team_object.budget_limits is exceeded.
|
||||
|
||||
Each window has its own Redis counter keyed by spend:team:{team_id}:window:{budget_duration}.
|
||||
Using budget_duration (not list index) keeps counters stable when windows are reordered
|
||||
or removed during a team update.
|
||||
"""
|
||||
if team_object is None or not team_object.budget_limits:
|
||||
return
|
||||
|
||||
from litellm.proxy.proxy_server import get_current_spend
|
||||
|
||||
for window in team_object.budget_limits:
|
||||
w: dict = window if isinstance(window, dict) else window.model_dump()
|
||||
counter_key = f"spend:team:{team_object.team_id}:window:{w['budget_duration']}"
|
||||
window_spend = await get_current_spend(
|
||||
counter_key=counter_key,
|
||||
fallback_spend=0.0,
|
||||
)
|
||||
if window_spend >= w["max_budget"]:
|
||||
raise litellm.BudgetExceededError(
|
||||
current_cost=window_spend,
|
||||
max_budget=w["max_budget"],
|
||||
message=(
|
||||
f"ExceededBudget: Team={team_object.team_id} over {w['budget_duration']} budget. "
|
||||
f"Spend=${window_spend:.4f}, Limit=${w['max_budget']:.2f}"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def _team_soft_budget_check(
|
||||
team_object: Optional[LiteLLM_TeamTable],
|
||||
valid_token: Optional[UserAPIKeyAuth],
|
||||
|
||||
@@ -106,7 +106,7 @@ class UserAPIKeyAuthExceptionHandler:
|
||||
message=e.message,
|
||||
type=ProxyErrorTypes.budget_exceeded,
|
||||
param=None,
|
||||
code=400,
|
||||
code=429,
|
||||
)
|
||||
if isinstance(e, HTTPException):
|
||||
raise ProxyException(
|
||||
|
||||
@@ -984,9 +984,9 @@ async def _user_api_key_auth_builder( # noqa: PLR0915
|
||||
route=route,
|
||||
)
|
||||
if _end_user_object is not None:
|
||||
end_user_params[
|
||||
"allowed_model_region"
|
||||
] = _end_user_object.allowed_model_region
|
||||
end_user_params["allowed_model_region"] = (
|
||||
_end_user_object.allowed_model_region
|
||||
)
|
||||
if _end_user_object.litellm_budget_table is not None:
|
||||
_apply_budget_limits_to_end_user_params(
|
||||
end_user_params=end_user_params,
|
||||
@@ -1540,9 +1540,9 @@ async def _user_api_key_auth_builder( # noqa: PLR0915
|
||||
|
||||
if _end_user_object is not None:
|
||||
valid_token_dict.update(end_user_params)
|
||||
valid_token_dict[
|
||||
"end_user_object_permission"
|
||||
] = _end_user_object.object_permission
|
||||
valid_token_dict["end_user_object_permission"] = (
|
||||
_end_user_object.object_permission
|
||||
)
|
||||
|
||||
# check if token is from litellm-ui, litellm ui makes keys to allow users to login with sso. These keys can only be used for LiteLLM UI functions
|
||||
# sso/login, ui/login, /key functions and /user functions
|
||||
|
||||
@@ -2,7 +2,7 @@ import asyncio
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import List, Literal, Optional, Union
|
||||
from typing import Any, List, Literal, Optional, Union
|
||||
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.proxy._types import (
|
||||
@@ -48,6 +48,9 @@ class ResetBudgetJob:
|
||||
### RESET ENDUSER (Customer) BUDGET and corresponding Budget duration ###
|
||||
await self.reset_budget_for_litellm_budget_table()
|
||||
|
||||
### RESET MULTI-WINDOW BUDGETS ###
|
||||
await self.reset_budget_windows()
|
||||
|
||||
async def reset_budget_for_litellm_team_members(
|
||||
self, budgets_to_reset: List[LiteLLM_BudgetTableFull]
|
||||
):
|
||||
@@ -549,6 +552,102 @@ class ResetBudgetJob:
|
||||
)
|
||||
verbose_proxy_logger.exception("Failed to reset budget for teams: %s", e)
|
||||
|
||||
@staticmethod
|
||||
async def _reset_expired_window(
|
||||
window: dict,
|
||||
counter_key: str,
|
||||
spend_counter_cache: Any,
|
||||
now: datetime,
|
||||
) -> bool:
|
||||
"""Reset a single budget window if expired. Returns True if the window was reset."""
|
||||
from litellm.proxy.common_utils.timezone_utils import get_budget_reset_time
|
||||
|
||||
reset_at_str = window.get("reset_at")
|
||||
if not reset_at_str:
|
||||
return False
|
||||
reset_at = datetime.fromisoformat(
|
||||
reset_at_str.replace("Z", "+00:00")
|
||||
).replace(tzinfo=None)
|
||||
if reset_at > now:
|
||||
return False
|
||||
spend_counter_cache.in_memory_cache.set_cache(key=counter_key, value=0.0)
|
||||
if spend_counter_cache.redis_cache is not None:
|
||||
try:
|
||||
await spend_counter_cache.redis_cache.async_set_cache(
|
||||
key=counter_key, value=0.0
|
||||
)
|
||||
except Exception as redis_err:
|
||||
verbose_proxy_logger.warning(
|
||||
"Failed to reset Redis counter %s: %s", counter_key, redis_err
|
||||
)
|
||||
window["reset_at"] = get_budget_reset_time(
|
||||
budget_duration=window["budget_duration"]
|
||||
).isoformat()
|
||||
return True
|
||||
|
||||
async def reset_budget_windows(self) -> None:
|
||||
"""
|
||||
For keys and teams with budget_limits, reset any individual windows where
|
||||
reset_at <= now. Only the expired windows are reset; other windows are untouched.
|
||||
"""
|
||||
from litellm.proxy.proxy_server import spend_counter_cache
|
||||
|
||||
now = datetime.utcnow()
|
||||
|
||||
# --- Keys ---
|
||||
try:
|
||||
all_keys = await self.prisma_client.db.litellm_verificationtoken.find_many(
|
||||
where={"budget_limits": {"not": None}} # type: ignore[arg-type]
|
||||
)
|
||||
for key in all_keys:
|
||||
raw = key.budget_limits # type: ignore[attr-defined]
|
||||
if not raw:
|
||||
continue
|
||||
windows: list = raw if isinstance(raw, list) else json.loads(raw)
|
||||
changed = False
|
||||
for window in windows:
|
||||
counter_key = f"spend:key:{key.token}:window:{window['budget_duration']}"
|
||||
if await ResetBudgetJob._reset_expired_window(
|
||||
window, counter_key, spend_counter_cache, now
|
||||
):
|
||||
changed = True
|
||||
if changed:
|
||||
await self.prisma_client.db.litellm_verificationtoken.update(
|
||||
where={"token": key.token},
|
||||
data={"budget_limits": json.dumps(windows)}, # type: ignore[arg-type]
|
||||
)
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.exception(
|
||||
"Failed to reset budget windows for keys: %s", e
|
||||
)
|
||||
|
||||
# --- Teams ---
|
||||
try:
|
||||
all_teams = await self.prisma_client.db.litellm_teamtable.find_many(
|
||||
where={"budget_limits": {"not": None}} # type: ignore[arg-type]
|
||||
)
|
||||
for team in all_teams:
|
||||
raw = team.budget_limits # type: ignore[attr-defined]
|
||||
if not raw:
|
||||
continue
|
||||
windows = raw if isinstance(raw, list) else json.loads(raw)
|
||||
changed = False
|
||||
for window in windows:
|
||||
counter_key = f"spend:team:{team.team_id}:window:{window['budget_duration']}"
|
||||
if await ResetBudgetJob._reset_expired_window(
|
||||
window, counter_key, spend_counter_cache, now
|
||||
):
|
||||
changed = True
|
||||
if changed:
|
||||
await self.prisma_client.db.litellm_teamtable.update(
|
||||
where={"team_id": team.team_id},
|
||||
data={"budget_limits": json.dumps(windows)}, # type: ignore[arg-type]
|
||||
)
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.exception(
|
||||
"Failed to reset budget windows for teams: %s", e
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def _reset_budget_common(
|
||||
item: Union[LiteLLM_TeamTable, LiteLLM_UserTable, LiteLLM_VerificationToken],
|
||||
@@ -570,14 +669,14 @@ class ResetBudgetJob:
|
||||
from litellm.proxy.proxy_server import spend_counter_cache
|
||||
|
||||
counter_key = None
|
||||
if item_type == "key" and hasattr(item, "token") and item.token is not None:
|
||||
counter_key = f"spend:key:{item.token}"
|
||||
if item_type == "key" and hasattr(item, "token") and item.token is not None: # type: ignore[union-attr]
|
||||
counter_key = f"spend:key:{item.token}" # type: ignore[union-attr]
|
||||
elif (
|
||||
item_type == "team"
|
||||
and hasattr(item, "team_id")
|
||||
and item.team_id is not None
|
||||
and item.team_id is not None # type: ignore[union-attr]
|
||||
):
|
||||
counter_key = f"spend:team:{item.team_id}"
|
||||
counter_key = f"spend:team:{item.team_id}" # type: ignore[union-attr]
|
||||
|
||||
if counter_key is not None:
|
||||
# Always reset in-memory (local fallback)
|
||||
|
||||
@@ -744,9 +744,9 @@ async def _common_key_generation_helper( # noqa: PLR0915
|
||||
request_type="key", **data_json, table_name="key"
|
||||
)
|
||||
|
||||
response[
|
||||
"soft_budget"
|
||||
] = data.soft_budget # include the user-input soft budget in the response
|
||||
response["soft_budget"] = (
|
||||
data.soft_budget
|
||||
) # include the user-input soft budget in the response
|
||||
|
||||
response = GenerateKeyResponse(**response)
|
||||
|
||||
@@ -1571,6 +1571,19 @@ async def prepare_key_update_data(
|
||||
non_default_values["budget_reset_at"] = key_reset_at
|
||||
non_default_values["budget_duration"] = budget_duration
|
||||
|
||||
if "budget_limits" in non_default_values and non_default_values["budget_limits"]:
|
||||
from litellm.proxy.common_utils.timezone_utils import get_budget_reset_time
|
||||
|
||||
raw_windows = non_default_values["budget_limits"]
|
||||
initialized_windows = []
|
||||
for window in raw_windows:
|
||||
w = window if isinstance(window, dict) else window.model_dump()
|
||||
w["reset_at"] = get_budget_reset_time(
|
||||
budget_duration=w["budget_duration"]
|
||||
).isoformat()
|
||||
initialized_windows.append(w)
|
||||
non_default_values["budget_limits"] = json.dumps(initialized_windows)
|
||||
|
||||
if "object_permission" in non_default_values:
|
||||
non_default_values = await _handle_update_object_permission(
|
||||
data_json=non_default_values,
|
||||
@@ -2832,6 +2845,7 @@ async def generate_key_helper_fn( # noqa: PLR0915
|
||||
rotation_interval: Optional[str] = None,
|
||||
router_settings: Optional[dict] = None,
|
||||
access_group_ids: Optional[list] = None,
|
||||
budget_limits: Optional[list] = None, # multiple concurrent budget windows
|
||||
):
|
||||
from litellm.proxy.proxy_server import premium_user, prisma_client
|
||||
|
||||
@@ -2863,6 +2877,18 @@ async def generate_key_helper_fn( # noqa: PLR0915
|
||||
else:
|
||||
reset_at = get_budget_reset_time(budget_duration=budget_duration)
|
||||
|
||||
# Initialize reset_at for each budget window
|
||||
budget_limits_json: Optional[str] = None
|
||||
if budget_limits:
|
||||
initialized_windows = []
|
||||
for window in budget_limits:
|
||||
w = dict(window) if not isinstance(window, dict) else {**window}
|
||||
w["reset_at"] = get_budget_reset_time(
|
||||
budget_duration=w["budget_duration"]
|
||||
).isoformat()
|
||||
initialized_windows.append(w)
|
||||
budget_limits_json = json.dumps(initialized_windows)
|
||||
|
||||
aliases_json = json.dumps(aliases)
|
||||
config_json = json.dumps(config)
|
||||
permissions_json = json.dumps(permissions)
|
||||
@@ -2945,6 +2971,7 @@ async def generate_key_helper_fn( # noqa: PLR0915
|
||||
"organization_id": organization_id,
|
||||
"budget_id": budget_id,
|
||||
"blocked": blocked,
|
||||
"budget_limits": budget_limits_json,
|
||||
"created_by": created_by,
|
||||
"updated_by": updated_by,
|
||||
"allowed_routes": allowed_routes or [],
|
||||
@@ -3222,10 +3249,10 @@ async def delete_verification_tokens(
|
||||
try:
|
||||
if prisma_client:
|
||||
tokens = [_hash_token_if_needed(token=key) for key in tokens]
|
||||
_keys_being_deleted: List[
|
||||
LiteLLM_VerificationToken
|
||||
] = await prisma_client.db.litellm_verificationtoken.find_many(
|
||||
where={"token": {"in": tokens}}
|
||||
_keys_being_deleted: List[LiteLLM_VerificationToken] = (
|
||||
await prisma_client.db.litellm_verificationtoken.find_many(
|
||||
where={"token": {"in": tokens}}
|
||||
)
|
||||
)
|
||||
|
||||
if len(_keys_being_deleted) == 0:
|
||||
@@ -3425,9 +3452,9 @@ async def _rotate_master_key( # noqa: PLR0915
|
||||
from litellm.proxy.proxy_server import proxy_config
|
||||
|
||||
try:
|
||||
models: Optional[
|
||||
List
|
||||
] = await prisma_client.db.litellm_proxymodeltable.find_many()
|
||||
models: Optional[List] = (
|
||||
await prisma_client.db.litellm_proxymodeltable.find_many()
|
||||
)
|
||||
except Exception:
|
||||
models = None
|
||||
# 2. process model table
|
||||
@@ -4067,11 +4094,11 @@ async def validate_key_list_check(
|
||||
param="user_id",
|
||||
code=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
complete_user_info_db_obj: Optional[
|
||||
BaseModel
|
||||
] = await prisma_client.db.litellm_usertable.find_unique(
|
||||
where={"user_id": user_api_key_dict.user_id},
|
||||
include={"organization_memberships": True},
|
||||
complete_user_info_db_obj: Optional[BaseModel] = (
|
||||
await prisma_client.db.litellm_usertable.find_unique(
|
||||
where={"user_id": user_api_key_dict.user_id},
|
||||
include={"organization_memberships": True},
|
||||
)
|
||||
)
|
||||
|
||||
if complete_user_info_db_obj is None:
|
||||
@@ -4154,10 +4181,10 @@ async def _fetch_user_team_objects(
|
||||
if complete_user_info is None or not complete_user_info.teams:
|
||||
return []
|
||||
|
||||
teams: Optional[
|
||||
List[BaseModel]
|
||||
] = await prisma_client.db.litellm_teamtable.find_many(
|
||||
where={"team_id": {"in": complete_user_info.teams}}
|
||||
teams: Optional[List[BaseModel]] = (
|
||||
await prisma_client.db.litellm_teamtable.find_many(
|
||||
where={"team_id": {"in": complete_user_info.teams}}
|
||||
)
|
||||
)
|
||||
if teams is None:
|
||||
return []
|
||||
|
||||
@@ -978,6 +978,19 @@ async def new_team( # noqa: PLR0915
|
||||
budget_duration=complete_team_data.budget_duration,
|
||||
)
|
||||
|
||||
# If budget_limits is set, initialize reset_at for each window
|
||||
if complete_team_data.budget_limits:
|
||||
from litellm.proxy.common_utils.timezone_utils import get_budget_reset_time
|
||||
|
||||
initialized_windows = []
|
||||
for window in complete_team_data.budget_limits:
|
||||
w = window if isinstance(window, dict) else window.model_dump()
|
||||
w["reset_at"] = get_budget_reset_time(
|
||||
budget_duration=w["budget_duration"]
|
||||
).isoformat()
|
||||
initialized_windows.append(w)
|
||||
complete_team_data.budget_limits = initialized_windows
|
||||
|
||||
## Add Team Member Budget Table
|
||||
members_with_roles: List[Member] = []
|
||||
if complete_team_data.members_with_roles is not None:
|
||||
@@ -1593,6 +1606,18 @@ def _set_budget_reset_at(data: UpdateTeamRequest, updated_kv: dict) -> None:
|
||||
reset_at = get_budget_reset_time(budget_duration=data.budget_duration)
|
||||
updated_kv["budget_reset_at"] = reset_at
|
||||
|
||||
if data.budget_limits is not None and len(data.budget_limits) > 0:
|
||||
from litellm.proxy.common_utils.timezone_utils import get_budget_reset_time
|
||||
|
||||
initialized_windows = []
|
||||
for window in data.budget_limits:
|
||||
w = window if isinstance(window, dict) else window.model_dump()
|
||||
w["reset_at"] = get_budget_reset_time(
|
||||
budget_duration=w["budget_duration"]
|
||||
).isoformat()
|
||||
initialized_windows.append(w)
|
||||
updated_kv["budget_limits"] = json.dumps(initialized_windows)
|
||||
|
||||
|
||||
async def handle_update_object_permission(
|
||||
data_json: dict, existing_team_row: LiteLLM_TeamTable
|
||||
|
||||
@@ -1781,6 +1781,26 @@ async def increment_spend_counters(
|
||||
increment=response_cost,
|
||||
)
|
||||
|
||||
# Increment per-window budget counters for multi-budget keys
|
||||
key_obj = await user_api_key_cache.async_get_cache(key=hashed_token)
|
||||
if key_obj is not None:
|
||||
key_budget_limits = getattr(key_obj, "budget_limits", None) or (
|
||||
key_obj.get("budget_limits") if isinstance(key_obj, dict) else None
|
||||
)
|
||||
if isinstance(key_budget_limits, str):
|
||||
key_budget_limits = json.loads(key_budget_limits)
|
||||
if isinstance(key_budget_limits, list):
|
||||
for window in key_budget_limits:
|
||||
duration = (
|
||||
window["budget_duration"]
|
||||
if isinstance(window, dict)
|
||||
else window.budget_duration
|
||||
)
|
||||
await spend_counter_cache.async_increment_cache(
|
||||
key=f"spend:key:{hashed_token}:window:{duration}",
|
||||
value=response_cost,
|
||||
)
|
||||
|
||||
if team_id is not None:
|
||||
await _init_and_increment_spend_counter(
|
||||
counter_key=f"spend:team:{team_id}",
|
||||
@@ -1788,6 +1808,26 @@ async def increment_spend_counters(
|
||||
increment=response_cost,
|
||||
)
|
||||
|
||||
# Increment per-window budget counters for multi-budget teams
|
||||
team_obj = await user_api_key_cache.async_get_cache(key=f"team_id:{team_id}")
|
||||
if team_obj is not None:
|
||||
team_budget_limits = getattr(team_obj, "budget_limits", None) or (
|
||||
team_obj.get("budget_limits") if isinstance(team_obj, dict) else None
|
||||
)
|
||||
if isinstance(team_budget_limits, str):
|
||||
team_budget_limits = json.loads(team_budget_limits)
|
||||
if isinstance(team_budget_limits, list):
|
||||
for window in team_budget_limits:
|
||||
duration = (
|
||||
window["budget_duration"]
|
||||
if isinstance(window, dict)
|
||||
else window.budget_duration
|
||||
)
|
||||
await spend_counter_cache.async_increment_cache(
|
||||
key=f"spend:team:{team_id}:window:{duration}",
|
||||
value=response_cost,
|
||||
)
|
||||
|
||||
if user_id is not None and team_id is not None:
|
||||
await _init_and_increment_spend_counter(
|
||||
counter_key=f"spend:team_member:{user_id}:{team_id}",
|
||||
|
||||
@@ -247,6 +247,49 @@ async def get_mcp_servers():
|
||||
]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/public/skill_hub",
|
||||
tags=["public", "Claude Code Marketplace"],
|
||||
)
|
||||
async def public_skill_hub():
|
||||
"""Return enabled (public) Claude Code skills — no auth required."""
|
||||
from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_marketplace import (
|
||||
_get_prisma_client,
|
||||
)
|
||||
from litellm.types.proxy.claude_code_endpoints import ListPluginsResponse, PluginListItem
|
||||
|
||||
try:
|
||||
prisma_client = await _get_prisma_client()
|
||||
plugins = await prisma_client.db.litellm_claudecodeplugintable.find_many(
|
||||
where={"enabled": True}
|
||||
)
|
||||
items = []
|
||||
for plugin in plugins:
|
||||
raw = plugin.manifest_json or {}
|
||||
manifest = json.loads(raw) if isinstance(raw, str) else raw
|
||||
items.append(
|
||||
PluginListItem(
|
||||
id=plugin.id,
|
||||
name=plugin.name,
|
||||
enabled=plugin.enabled,
|
||||
created_at=str(plugin.created_at) if plugin.created_at else None,
|
||||
updated_at=str(plugin.updated_at) if plugin.updated_at else None,
|
||||
source=manifest.get("source", {}),
|
||||
description=manifest.get("description"),
|
||||
version=manifest.get("version"),
|
||||
category=manifest.get("category"),
|
||||
keywords=manifest.get("keywords"),
|
||||
author=manifest.get("author"),
|
||||
homepage=manifest.get("homepage"),
|
||||
domain=manifest.get("domain"),
|
||||
namespace=manifest.get("namespace"),
|
||||
)
|
||||
)
|
||||
return ListPluginsResponse(plugins=items, count=len(items))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get(
|
||||
"/public/model_hub/info",
|
||||
tags=["public", "model management"],
|
||||
|
||||
@@ -141,6 +141,7 @@ model LiteLLM_TeamTable {
|
||||
team_member_permissions String[] @default([])
|
||||
access_group_ids String[] @default([])
|
||||
policies String[] @default([])
|
||||
budget_limits Json? // multiple concurrent budget windows [{budget_duration, max_budget, reset_at}]
|
||||
default_team_member_models String[] @default([]) // default allowed_models for newly added team members; empty = no per-member restriction
|
||||
model_id Int? @unique // id for LiteLLM_ModelTable -> stores team-level model aliases
|
||||
allow_team_guardrail_config Boolean @default(false) // if true, team admin can configure guardrails for this team
|
||||
@@ -402,6 +403,7 @@ model LiteLLM_VerificationToken {
|
||||
rotation_interval String? // How often to rotate (e.g., "30d", "90d")
|
||||
last_rotation_at DateTime? // When this key was last rotated
|
||||
key_rotation_at DateTime? // When this key should next be rotated
|
||||
budget_limits Json? // multiple concurrent budget windows [{budget_duration, max_budget, reset_at}]
|
||||
litellm_budget_table LiteLLM_BudgetTable? @relation(fields: [budget_id], references: [budget_id])
|
||||
litellm_organization_table LiteLLM_OrganizationTable? @relation(fields: [organization_id], references: [organization_id])
|
||||
litellm_project_table LiteLLM_ProjectTable? @relation(fields: [project_id], references: [project_id])
|
||||
|
||||
+11
-11
@@ -1898,9 +1898,9 @@ class ProxyLogging:
|
||||
normalized_call_type = CallTypes.aembedding.value
|
||||
if normalized_call_type is not None:
|
||||
litellm_logging_obj.call_type = normalized_call_type
|
||||
litellm_logging_obj.model_call_details[
|
||||
"call_type"
|
||||
] = normalized_call_type
|
||||
litellm_logging_obj.model_call_details["call_type"] = (
|
||||
normalized_call_type
|
||||
)
|
||||
# Pass-through endpoints are logged via the callback loop's
|
||||
# async_post_call_failure_hook — skip pre_call and failure handlers.
|
||||
if litellm_logging_obj.call_type == CallTypes.pass_through.value:
|
||||
@@ -2524,8 +2524,7 @@ class PrismaClient:
|
||||
required_view = "LiteLLM_VerificationTokenView"
|
||||
expected_views_str = ", ".join(f"'{view}'" for view in expected_views)
|
||||
pg_schema = os.getenv("DATABASE_SCHEMA", "public")
|
||||
ret = await self.db.query_raw(
|
||||
f"""
|
||||
ret = await self.db.query_raw(f"""
|
||||
WITH existing_views AS (
|
||||
SELECT viewname
|
||||
FROM pg_views
|
||||
@@ -2537,8 +2536,7 @@ class PrismaClient:
|
||||
(SELECT COUNT(*) FROM existing_views) AS view_count,
|
||||
ARRAY_AGG(viewname) AS view_names
|
||||
FROM existing_views
|
||||
"""
|
||||
)
|
||||
""")
|
||||
expected_total_views = len(expected_views)
|
||||
if ret[0]["view_count"] == expected_total_views:
|
||||
verbose_proxy_logger.info("All necessary views exist!")
|
||||
@@ -2547,8 +2545,7 @@ class PrismaClient:
|
||||
## check if required view exists ##
|
||||
if ret[0]["view_names"] and required_view not in ret[0]["view_names"]:
|
||||
await self.health_check() # make sure we can connect to db
|
||||
await self.db.execute_raw(
|
||||
"""
|
||||
await self.db.execute_raw("""
|
||||
CREATE VIEW "LiteLLM_VerificationTokenView" AS
|
||||
SELECT
|
||||
v.*,
|
||||
@@ -2558,8 +2555,7 @@ class PrismaClient:
|
||||
t.rpm_limit AS team_rpm_limit
|
||||
FROM "LiteLLM_VerificationToken" v
|
||||
LEFT JOIN "LiteLLM_TeamTable" t ON v.team_id = t.team_id;
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
verbose_proxy_logger.info(
|
||||
"LiteLLM_VerificationTokenView Created in DB!"
|
||||
@@ -3142,6 +3138,10 @@ class PrismaClient:
|
||||
hashed_token = self.hash_token(token=token)
|
||||
db_data = self.jsonify_object(data=data)
|
||||
db_data["token"] = hashed_token
|
||||
# Prisma rejects nullable JSON fields set to None (no default).
|
||||
# Strip them so the DB stores NULL via the column's nullable constraint.
|
||||
if db_data.get("budget_limits") is None:
|
||||
db_data.pop("budget_limits", None)
|
||||
print_verbose(
|
||||
"PrismaClient: Before upsert into litellm_verificationtoken"
|
||||
)
|
||||
|
||||
@@ -49,6 +49,8 @@ class RegisterPluginRequest(BaseModel):
|
||||
homepage: Optional[str] = Field(None, description="Plugin homepage URL")
|
||||
keywords: Optional[List[str]] = Field(None, description="Search keywords")
|
||||
category: Optional[str] = Field(None, description="Plugin category")
|
||||
domain: Optional[str] = Field(None, description="Skill domain (e.g., 'Productivity')")
|
||||
namespace: Optional[str] = Field(None, description="Skill namespace within domain (e.g., 'workflows')")
|
||||
|
||||
|
||||
class PluginResponse(BaseModel):
|
||||
@@ -82,6 +84,8 @@ class PluginListItem(BaseModel):
|
||||
homepage: Optional[str] = None
|
||||
keywords: Optional[List[str]] = None
|
||||
category: Optional[str] = None
|
||||
domain: Optional[str] = None
|
||||
namespace: Optional[str] = None
|
||||
enabled: bool
|
||||
created_at: Optional[str]
|
||||
updated_at: Optional[str]
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
"""
|
||||
Unit tests for multi-budget-window enforcement on API keys.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
import litellm
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.proxy.auth.auth_checks import _virtual_key_multi_budget_check
|
||||
|
||||
|
||||
def _make_valid_token(**kwargs) -> UserAPIKeyAuth:
|
||||
defaults = dict(
|
||||
token="sk-test-token",
|
||||
key_name="test",
|
||||
spend=0.0,
|
||||
max_budget=None,
|
||||
budget_limits=[],
|
||||
)
|
||||
defaults.update(kwargs)
|
||||
return UserAPIKeyAuth(**defaults)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_budget_limits_passes():
|
||||
"""Keys with empty budget_limits should pass without raising."""
|
||||
token = _make_valid_token(budget_limits=[])
|
||||
# Should not raise
|
||||
await _virtual_key_multi_budget_check(valid_token=token)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_under_budget_passes():
|
||||
"""Key with spend under all windows should pass."""
|
||||
token = _make_valid_token(
|
||||
budget_limits=[
|
||||
{"budget_duration": "24h", "max_budget": 10.0, "reset_at": None},
|
||||
{"budget_duration": "30d", "max_budget": 100.0, "reset_at": None},
|
||||
]
|
||||
)
|
||||
with patch(
|
||||
"litellm.proxy.proxy_server.get_current_spend",
|
||||
new_callable=AsyncMock,
|
||||
return_value=1.0, # well under both windows
|
||||
):
|
||||
await _virtual_key_multi_budget_check(valid_token=token)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_over_first_window_raises():
|
||||
"""Key exceeding the first (daily) window should raise BudgetExceededError."""
|
||||
token = _make_valid_token(
|
||||
budget_limits=[
|
||||
{"budget_duration": "24h", "max_budget": 5.0, "reset_at": None},
|
||||
{"budget_duration": "30d", "max_budget": 100.0, "reset_at": None},
|
||||
]
|
||||
)
|
||||
|
||||
spend_by_window = [6.0, 6.0] # over daily, under monthly
|
||||
|
||||
call_count = 0
|
||||
|
||||
async def fake_get_spend(counter_key, fallback_spend):
|
||||
nonlocal call_count
|
||||
val = spend_by_window[call_count]
|
||||
call_count += 1
|
||||
return val
|
||||
|
||||
with patch(
|
||||
"litellm.proxy.proxy_server.get_current_spend", side_effect=fake_get_spend
|
||||
):
|
||||
with pytest.raises(litellm.BudgetExceededError) as exc_info:
|
||||
await _virtual_key_multi_budget_check(valid_token=token)
|
||||
|
||||
err = exc_info.value
|
||||
assert err.status_code == 429
|
||||
assert "24h" in str(err)
|
||||
assert "Key over" in str(err)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_over_second_window_raises():
|
||||
"""Key exceeding only the monthly window should raise BudgetExceededError referencing 30d."""
|
||||
token = _make_valid_token(
|
||||
budget_limits=[
|
||||
{"budget_duration": "24h", "max_budget": 50.0, "reset_at": None},
|
||||
{"budget_duration": "30d", "max_budget": 5.0, "reset_at": None},
|
||||
]
|
||||
)
|
||||
|
||||
spend_by_window = [1.0, 10.0] # under daily, over monthly
|
||||
|
||||
call_count = 0
|
||||
|
||||
async def fake_get_spend(counter_key, fallback_spend):
|
||||
nonlocal call_count
|
||||
val = spend_by_window[call_count]
|
||||
call_count += 1
|
||||
return val
|
||||
|
||||
with patch(
|
||||
"litellm.proxy.proxy_server.get_current_spend", side_effect=fake_get_spend
|
||||
):
|
||||
with pytest.raises(litellm.BudgetExceededError) as exc_info:
|
||||
await _virtual_key_multi_budget_check(valid_token=token)
|
||||
|
||||
err = exc_info.value
|
||||
assert err.status_code == 429
|
||||
assert "30d" in str(err)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_budget_limit_entry_objects_coerced():
|
||||
"""BudgetLimitEntry Pydantic objects (not dicts) must be handled without KeyError.
|
||||
|
||||
While budget_limits is normally serialized as List[dict], the auth check must
|
||||
tolerate BudgetLimitEntry objects in case they arrive without prior serialization.
|
||||
"""
|
||||
from litellm.proxy._types import BudgetLimitEntry
|
||||
|
||||
token = _make_valid_token(budget_limits=[])
|
||||
# Bypass Pydantic validation to simulate BudgetLimitEntry objects reaching the check
|
||||
object.__setattr__(
|
||||
token,
|
||||
"budget_limits",
|
||||
[BudgetLimitEntry(budget_duration="24h", max_budget=10.0)],
|
||||
)
|
||||
|
||||
with patch(
|
||||
"litellm.proxy.proxy_server.get_current_spend",
|
||||
new_callable=AsyncMock,
|
||||
return_value=1.0,
|
||||
):
|
||||
# Should not raise TypeError / KeyError — model_dump() coerces the object
|
||||
await _virtual_key_multi_budget_check(valid_token=token)
|
||||
Generated
+5
-77
@@ -91,7 +91,6 @@
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
|
||||
"integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
@@ -1773,7 +1772,6 @@
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||
@@ -1784,7 +1782,6 @@
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
@@ -1794,14 +1791,12 @@
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/trace-mapping": {
|
||||
"version": "0.3.31",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
||||
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/resolve-uri": "^3.1.0",
|
||||
@@ -1969,7 +1964,6 @@
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nodelib/fs.stat": "2.0.5",
|
||||
@@ -1983,7 +1977,6 @@
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
|
||||
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
@@ -1993,7 +1986,6 @@
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
|
||||
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nodelib/fs.scandir": "2.1.5",
|
||||
@@ -2317,7 +2309,7 @@
|
||||
"version": "1.58.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.1.tgz",
|
||||
"integrity": "sha512-6LdVIUERWxQMmUSSQi0I53GgCBYgM2RpGngCPY7hSeju+VrKjq3lvs7HpJoPbDiY5QM5EYRtRX5fvrinnMAz3w==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.58.1"
|
||||
@@ -3422,14 +3414,12 @@
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "18.2.48",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.48.tgz",
|
||||
"integrity": "sha512-qboRCl6Ie70DQQG9hhNREz81jqC1cs9EVNcjQ1AU+jH6NFfSAhVVbrrY/+nSF+Bsk4AOwm9Qa61InvMCyV+H3w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
@@ -3471,7 +3461,6 @@
|
||||
"version": "0.26.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.26.0.tgz",
|
||||
"integrity": "sha512-WFHp9YUJQ6CKshqoC37iOlHnQSmxNc795UhB26CyBBttrN9svdIrUjl/NjnNmfcwtncN0h/0PPAFWv9ovP8mLA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/unist": {
|
||||
@@ -4332,14 +4321,12 @@
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
|
||||
"integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/anymatch": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
|
||||
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"normalize-path": "^3.0.0",
|
||||
@@ -4353,7 +4340,6 @@
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
@@ -4366,7 +4352,6 @@
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
|
||||
"integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/argparse": {
|
||||
@@ -4726,7 +4711,6 @@
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -4752,7 +4736,6 @@
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
||||
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fill-range": "^7.1.1"
|
||||
@@ -4868,7 +4851,6 @@
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
|
||||
"integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
@@ -4992,7 +4974,6 @@
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"anymatch": "~3.1.2",
|
||||
@@ -5017,7 +4998,6 @@
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
||||
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"is-glob": "^4.0.1"
|
||||
@@ -5093,7 +5073,6 @@
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
|
||||
"integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
@@ -5154,7 +5133,6 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"cssesc": "bin/cssesc"
|
||||
@@ -5568,14 +5546,12 @@
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
|
||||
"integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/dlv": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
|
||||
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/doctrine": {
|
||||
@@ -6489,7 +6465,6 @@
|
||||
"version": "1.20.1",
|
||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
|
||||
"integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"reusify": "^1.0.4"
|
||||
@@ -6522,7 +6497,6 @@
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
@@ -6560,7 +6534,6 @@
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"to-regex-range": "^5.0.1"
|
||||
@@ -6700,7 +6673,6 @@
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
@@ -6851,7 +6823,6 @@
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||
"integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"is-glob": "^4.0.3"
|
||||
@@ -7349,7 +7320,6 @@
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"binary-extensions": "^2.0.0"
|
||||
@@ -7402,7 +7372,6 @@
|
||||
"version": "2.16.1",
|
||||
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
|
||||
"integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"hasown": "^2.0.2"
|
||||
@@ -7463,7 +7432,6 @@
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -7509,7 +7477,6 @@
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
||||
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-extglob": "^2.1.1"
|
||||
@@ -7558,7 +7525,6 @@
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
||||
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.12.0"
|
||||
@@ -7835,7 +7801,6 @@
|
||||
"version": "1.21.7",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
|
||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"jiti": "bin/jiti.js"
|
||||
@@ -8121,7 +8086,6 @@
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
||||
"integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
@@ -8134,7 +8098,6 @@
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
||||
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
@@ -8588,7 +8551,6 @@
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
@@ -9161,7 +9123,6 @@
|
||||
"version": "4.0.8",
|
||||
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
||||
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"braces": "^3.0.3",
|
||||
@@ -9175,7 +9136,6 @@
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
@@ -9290,7 +9250,6 @@
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
|
||||
"integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"any-promise": "^1.0.0",
|
||||
@@ -9502,7 +9461,6 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -9521,7 +9479,6 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
|
||||
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
@@ -9866,7 +9823,6 @@
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
|
||||
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/path-scurry": {
|
||||
@@ -9913,7 +9869,6 @@
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -9926,7 +9881,6 @@
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
|
||||
"integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -9936,7 +9890,6 @@
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
|
||||
"integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
@@ -9946,7 +9899,7 @@
|
||||
"version": "1.58.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.1.tgz",
|
||||
"integrity": "sha512-+2uTZHxSCcxjvGc5C891LrS1/NlxglGxzrC4seZiVjcYVQfUa87wBL6rTDqzGjuoWNjnBzRqKmF6zRYGMvQUaQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.58.1"
|
||||
@@ -9965,7 +9918,7 @@
|
||||
"version": "1.58.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.1.tgz",
|
||||
"integrity": "sha512-bcWzOaTxcW+VOOGBCQgnaKToLJ65d6AqfLVKEWvexyS3AS6rbXl+xdpYRMGSRBClPvyj44njOWoxjNdL/H9UNg==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
@@ -9988,7 +9941,6 @@
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -10017,7 +9969,6 @@
|
||||
"version": "15.1.0",
|
||||
"resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz",
|
||||
"integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"postcss-value-parser": "^4.0.0",
|
||||
@@ -10035,7 +9986,6 @@
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz",
|
||||
"integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -10061,7 +10011,6 @@
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz",
|
||||
"integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -10104,7 +10053,6 @@
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz",
|
||||
"integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -10130,7 +10078,6 @@
|
||||
"version": "6.1.2",
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
|
||||
"integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cssesc": "^3.0.0",
|
||||
@@ -10144,7 +10091,6 @@
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
|
||||
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/prelude-ls": {
|
||||
@@ -10251,7 +10197,6 @@
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -11040,7 +10985,6 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||
"integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pify": "^2.3.0"
|
||||
@@ -11050,7 +10994,6 @@
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"picomatch": "^2.2.1"
|
||||
@@ -11063,7 +11006,6 @@
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
@@ -11361,7 +11303,6 @@
|
||||
"version": "1.22.11",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||
"integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-core-module": "^2.16.1",
|
||||
@@ -11402,7 +11343,6 @@
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
|
||||
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"iojs": ">=1.0.0",
|
||||
@@ -11458,7 +11398,6 @@
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
||||
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -12099,7 +12038,6 @@
|
||||
"version": "3.35.1",
|
||||
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
|
||||
"integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.2",
|
||||
@@ -12135,7 +12073,6 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
|
||||
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -12171,7 +12108,6 @@
|
||||
"version": "3.4.19",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
|
||||
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
@@ -12209,7 +12145,6 @@
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
||||
"integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nodelib/fs.stat": "^2.0.2",
|
||||
@@ -12226,7 +12161,6 @@
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
||||
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"is-glob": "^4.0.1"
|
||||
@@ -12254,7 +12188,6 @@
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
|
||||
"integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"any-promise": "^1.0.0"
|
||||
@@ -12264,7 +12197,6 @@
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
|
||||
"integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"thenify": ">= 3.1.0 < 4"
|
||||
@@ -12306,7 +12238,6 @@
|
||||
"version": "0.2.15",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fdir": "^6.5.0",
|
||||
@@ -12373,7 +12304,6 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-number": "^7.0.0"
|
||||
@@ -12461,7 +12391,6 @@
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
|
||||
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/tsconfig-paths": {
|
||||
@@ -12578,7 +12507,7 @@
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
@@ -12780,7 +12709,6 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
@@ -13234,7 +13162,7 @@
|
||||
"version": "8.19.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import ClaudeCodePluginsPanel from "@/components/claude_code_plugins";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
|
||||
const SkillsPage = () => {
|
||||
const { accessToken, userRole } = useAuthorized();
|
||||
|
||||
return (
|
||||
<ClaudeCodePluginsPanel
|
||||
accessToken={accessToken}
|
||||
userRole={userRole}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default SkillsPage;
|
||||
@@ -624,7 +624,7 @@ function CreateKeyPageContent() {
|
||||
<SearchTools accessToken={accessToken} userRole={userRole} userID={userID} />
|
||||
) : page == "tag-management" ? (
|
||||
<TagManagement accessToken={accessToken} userRole={userRole} userID={userID} />
|
||||
) : page == "claude-code-plugins" ? (
|
||||
) : page == "skills" || page == "claude-code-plugins" ? (
|
||||
<ClaudeCodePluginsPanel accessToken={accessToken} userRole={userRole} />
|
||||
) : page == "access-groups" ? (
|
||||
<AccessGroupsPage />
|
||||
|
||||
@@ -5,7 +5,10 @@ import MakeModelPublicForm from "@/components/AIHub/forms/MakeModelPublicForm";
|
||||
import { mcpHubColumns, MCPServerData } from "@/components/mcp_hub_table_columns";
|
||||
import { modelHubColumns } from "@/components/model_hub_table_columns";
|
||||
import UsefulLinksManagement from "@/components/AIHub/UsefulLinksManagement";
|
||||
import ClaudeCodeMarketplaceTab from "@/components/AIHub/ClaudeCodeMarketplaceTab";
|
||||
import { getClaudeCodePluginsList } from "@/components/networking";
|
||||
import { Plugin } from "@/components/claude_code_plugins/types";
|
||||
import SkillHubDashboard from "@/components/AIHub/SkillHubDashboard";
|
||||
import MakeSkillPublicForm from "@/components/claude_code_plugins/MakeSkillPublicForm";
|
||||
import { ModelDataTable } from "@/components/model_dashboard/table";
|
||||
import ModelFilters from "@/components/model_filters";
|
||||
import NotificationsManager from "@/components/molecules/notifications_manager";
|
||||
@@ -78,6 +81,10 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({ accessToken, publicPage,
|
||||
const [selectedMcpServer, setSelectedMcpServer] = useState<null | MCPServerData>(null);
|
||||
const [isMcpModalVisible, setIsMcpModalVisible] = useState(false);
|
||||
const [isMakeMcpPublicModalVisible, setIsMakeMcpPublicModalVisible] = useState(false);
|
||||
// Skill Hub state
|
||||
const [skillHubData, setSkillHubData] = useState<Plugin[]>([]);
|
||||
const [skillLoading, setSkillLoading] = useState<boolean>(false);
|
||||
const [isMakeSkillPublicModalVisible, setIsMakeSkillPublicModalVisible] = useState(false);
|
||||
const router = useRouter();
|
||||
const { data: uiSettings, isLoading: isUISettingsLoading } = useUISettings();
|
||||
|
||||
@@ -208,6 +215,25 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({ accessToken, publicPage,
|
||||
}
|
||||
}, [publicPage, accessToken]);
|
||||
|
||||
// Fetch Skill Hub data — all skills for admins, enabled-only for public page
|
||||
useEffect(() => {
|
||||
const fetchSkillData = async () => {
|
||||
if (!accessToken) return;
|
||||
try {
|
||||
setSkillLoading(true);
|
||||
const enabledOnly = publicPage === true;
|
||||
const response = await getClaudeCodePluginsList(accessToken, enabledOnly);
|
||||
setSkillHubData(response.plugins);
|
||||
} catch (error) {
|
||||
console.error("Error fetching skill hub data", error);
|
||||
} finally {
|
||||
setSkillLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSkillData();
|
||||
}, [accessToken, publicPage]);
|
||||
|
||||
const showModal = (model: ModelGroupInfo) => {
|
||||
setSelectedModel(model);
|
||||
setIsModalVisible(true);
|
||||
@@ -406,7 +432,7 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({ accessToken, publicPage,
|
||||
<Tab>Model Hub</Tab>
|
||||
<Tab>Agent Hub</Tab>
|
||||
<Tab>MCP Hub</Tab>
|
||||
<Tab>Claude Code Plugin Marketplace</Tab>
|
||||
<Tab>Skill Hub</Tab>
|
||||
</TabList>
|
||||
|
||||
<TabPanels>
|
||||
@@ -492,9 +518,26 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({ accessToken, publicPage,
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
||||
{/* Plugin Marketplace Tab */}
|
||||
{/* Skill Hub Tab */}
|
||||
<TabPanel>
|
||||
<ClaudeCodeMarketplaceTab publicPage={publicPage} />
|
||||
{publicPage == false && isAdminRole(userRole || "") && (
|
||||
<div className="flex justify-end mb-4">
|
||||
<Button onClick={() => setIsMakeSkillPublicModalVisible(true)}>
|
||||
Select Skills to Make Public
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<SkillHubDashboard
|
||||
skills={skillHubData}
|
||||
isLoading={skillLoading}
|
||||
isAdmin={isAdminRole(userRole || "")}
|
||||
accessToken={accessToken}
|
||||
publicPage={publicPage}
|
||||
onPublishSuccess={async () => {
|
||||
const response = await getClaudeCodePluginsList(accessToken || "", publicPage === true);
|
||||
setSkillHubData(response.plugins);
|
||||
}}
|
||||
/>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</TabGroup>
|
||||
@@ -1055,6 +1098,18 @@ if __name__ == "__main__":
|
||||
mcpHubData={mcpHubData || []}
|
||||
onSuccess={handleMakeMcpPublicSuccess}
|
||||
/>
|
||||
|
||||
{/* Make Skill Public Form */}
|
||||
<MakeSkillPublicForm
|
||||
visible={isMakeSkillPublicModalVisible}
|
||||
onClose={() => setIsMakeSkillPublicModalVisible(false)}
|
||||
accessToken={accessToken || ""}
|
||||
skillsList={skillHubData}
|
||||
onSuccess={async () => {
|
||||
const response = await getClaudeCodePluginsList(accessToken || "", publicPage === true);
|
||||
setSkillHubData(response.plugins);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { Text } from "@tremor/react";
|
||||
import { SearchOutlined } from "@ant-design/icons";
|
||||
import { Input, Select } from "antd";
|
||||
import { Plugin } from "@/components/claude_code_plugins/types";
|
||||
import { ModelDataTable } from "@/components/model_dashboard/table";
|
||||
import { skillHubColumns } from "@/components/skill_hub_table_columns";
|
||||
import SkillDetail from "@/components/claude_code_plugins/skill_detail";
|
||||
|
||||
interface SkillHubDashboardProps {
|
||||
skills: Plugin[];
|
||||
isLoading: boolean;
|
||||
isAdmin?: boolean;
|
||||
accessToken?: string | null;
|
||||
publicPage?: boolean;
|
||||
onPublishSuccess?: () => void;
|
||||
}
|
||||
|
||||
const SkillHubDashboard: React.FC<SkillHubDashboardProps> = ({
|
||||
skills,
|
||||
isLoading,
|
||||
isAdmin,
|
||||
accessToken,
|
||||
publicPage = false,
|
||||
onPublishSuccess,
|
||||
}) => {
|
||||
const [search, setSearch] = useState("");
|
||||
const [domainFilter, setDomainFilter] = useState<string | undefined>(undefined);
|
||||
const [selectedSkill, setSelectedSkill] = useState<Plugin | null>(null);
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
};
|
||||
|
||||
// Derived stats
|
||||
const totalSkills = skills.length;
|
||||
const domains = useMemo(() => [...new Set(skills.map((s) => s.domain).filter(Boolean))], [skills]);
|
||||
const namespaces = useMemo(() => [...new Set(skills.map((s) => s.namespace).filter(Boolean))], [skills]);
|
||||
|
||||
// Filtered table data
|
||||
const filteredSkills = useMemo(() => {
|
||||
let result = skills;
|
||||
if (domainFilter) {
|
||||
result = result.filter((s) => (s.domain || "General") === domainFilter);
|
||||
}
|
||||
if (search.trim()) {
|
||||
const q = search.toLowerCase();
|
||||
result = result.filter(
|
||||
(s) =>
|
||||
s.name.toLowerCase().includes(q) ||
|
||||
s.description?.toLowerCase().includes(q) ||
|
||||
s.domain?.toLowerCase().includes(q) ||
|
||||
s.namespace?.toLowerCase().includes(q) ||
|
||||
s.keywords?.some((k) => k.toLowerCase().includes(q))
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}, [skills, search, domainFilter]);
|
||||
|
||||
if (selectedSkill) {
|
||||
return (
|
||||
<SkillDetail
|
||||
skill={selectedSkill}
|
||||
onBack={() => setSelectedSkill(null)}
|
||||
isAdmin={isAdmin}
|
||||
accessToken={accessToken}
|
||||
onPublishClick={onPublishSuccess}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="text-center py-16 text-gray-400">Loading skills...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Stats row */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="border border-gray-200 rounded-lg p-4">
|
||||
<div className="text-xs text-gray-500 mb-1">Total Skills</div>
|
||||
<div className="text-2xl font-semibold text-gray-900">{totalSkills}</div>
|
||||
</div>
|
||||
<div className="border border-gray-200 rounded-lg p-4">
|
||||
<div className="text-xs text-gray-500 mb-1">Namespaces</div>
|
||||
<div className="text-2xl font-semibold text-gray-900">{namespaces.length}</div>
|
||||
</div>
|
||||
<div className="border border-gray-200 rounded-lg p-4">
|
||||
<div className="text-xs text-gray-500 mb-1">Domains</div>
|
||||
<div className="text-2xl font-semibold text-gray-900">{domains.length}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search + filters + table */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-gray-700">
|
||||
All {publicPage ? "Public " : ""}Skills
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
placeholder="All Domains"
|
||||
allowClear
|
||||
value={domainFilter}
|
||||
onChange={(val) => setDomainFilter(val)}
|
||||
style={{ width: 160 }}
|
||||
options={domains.map((d) => ({ label: d, value: d }))}
|
||||
/>
|
||||
<Input
|
||||
prefix={<SearchOutlined className="text-gray-400" />}
|
||||
placeholder="Search by name, namespace, or tag…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
style={{ width: 280 }}
|
||||
allowClear
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ModelDataTable
|
||||
columns={skillHubColumns(
|
||||
(skill) => setSelectedSkill(skill),
|
||||
copyToClipboard,
|
||||
publicPage
|
||||
)}
|
||||
data={filteredSkills}
|
||||
isLoading={false}
|
||||
defaultSorting={[{ id: "name", desc: false }]}
|
||||
/>
|
||||
<div className="mt-3 text-center">
|
||||
<Text className="text-sm text-gray-500">
|
||||
Showing {filteredSkills.length} of {totalSkills} skill{totalSkills !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SkillHubDashboard;
|
||||
@@ -7,8 +7,8 @@ import {
|
||||
} 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 PluginInfoView from "./claude_code_plugins/plugin_info";
|
||||
import NotificationsManager from "./molecules/notifications_manager";
|
||||
import { Plugin, ListPluginsResponse } from "./claude_code_plugins/types";
|
||||
|
||||
@@ -29,27 +29,22 @@ const ClaudeCodePluginsPanel: React.FC<ClaudeCodePluginsPanelProps> = ({
|
||||
name: string;
|
||||
displayName: string;
|
||||
} | null>(null);
|
||||
const [selectedPluginId, setSelectedPluginId] = useState<string | null>(
|
||||
null
|
||||
);
|
||||
const [selectedSkill, setSelectedSkill] = useState<Plugin | null>(null);
|
||||
|
||||
const isAdmin = userRole ? isAdminRole(userRole) : false;
|
||||
|
||||
const fetchPlugins = async () => {
|
||||
if (!accessToken) {
|
||||
return;
|
||||
}
|
||||
if (!accessToken) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response: ListPluginsResponse = await getClaudeCodePluginsList(
|
||||
accessToken,
|
||||
false // Get all plugins (enabled and disabled)
|
||||
false
|
||||
);
|
||||
console.log(`Claude Code plugins: ${JSON.stringify(response)}`);
|
||||
setPluginsList(response.plugins);
|
||||
} catch (error) {
|
||||
console.error("Error fetching Claude Code plugins:", error);
|
||||
console.error("Error fetching skills:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -59,21 +54,6 @@ const ClaudeCodePluginsPanel: React.FC<ClaudeCodePluginsPanelProps> = ({
|
||||
fetchPlugins();
|
||||
}, [accessToken]);
|
||||
|
||||
const handleAddPlugin = () => {
|
||||
if (selectedPluginId) {
|
||||
setSelectedPluginId(null);
|
||||
}
|
||||
setIsAddModalVisible(true);
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setIsAddModalVisible(false);
|
||||
};
|
||||
|
||||
const handleSuccess = () => {
|
||||
fetchPlugins();
|
||||
};
|
||||
|
||||
const handleDeleteClick = (pluginName: string, displayName: string) => {
|
||||
setPluginToDelete({ name: pluginName, displayName });
|
||||
};
|
||||
@@ -84,79 +64,76 @@ const ClaudeCodePluginsPanel: React.FC<ClaudeCodePluginsPanelProps> = ({
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await deleteClaudeCodePlugin(accessToken, pluginToDelete.name);
|
||||
NotificationsManager.success(
|
||||
`Plugin "${pluginToDelete.displayName}" deleted successfully`
|
||||
);
|
||||
NotificationsManager.success(`Skill "${pluginToDelete.displayName}" deleted successfully`);
|
||||
fetchPlugins();
|
||||
} catch (error) {
|
||||
console.error("Error deleting plugin:", error);
|
||||
NotificationsManager.error("Failed to delete plugin");
|
||||
console.error("Error deleting skill:", error);
|
||||
NotificationsManager.error("Failed to delete skill");
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
setPluginToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteCancel = () => {
|
||||
setPluginToDelete(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full mx-auto flex-auto overflow-y-auto m-8 p-2">
|
||||
<div className="flex flex-col gap-2 mb-4">
|
||||
<h1 className="text-2xl font-bold">Claude Code Plugins</h1>
|
||||
<p className="text-sm text-gray-600">
|
||||
Manage Claude Code marketplace plugins. Add, enable, disable, or
|
||||
delete plugins that will be available in your marketplace catalog.
|
||||
Enabled plugins will appear in the public marketplace at{" "}
|
||||
<code className="bg-gray-100 px-1 rounded">/claude-code/marketplace.json</code>.
|
||||
</p>
|
||||
<div className="mt-2">
|
||||
<Button onClick={handleAddPlugin} disabled={!accessToken || !isAdmin}>
|
||||
+ Add New Plugin
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedPluginId ? (
|
||||
<PluginInfoView
|
||||
pluginId={selectedPluginId}
|
||||
onClose={() => setSelectedPluginId(null)}
|
||||
accessToken={accessToken}
|
||||
{selectedSkill ? (
|
||||
<SkillDetail
|
||||
skill={selectedSkill}
|
||||
onBack={() => setSelectedSkill(null)}
|
||||
isAdmin={isAdmin}
|
||||
onPluginUpdated={fetchPlugins}
|
||||
accessToken={accessToken}
|
||||
onPublishClick={fetchPlugins}
|
||||
/>
|
||||
) : (
|
||||
<PluginTable
|
||||
pluginsList={pluginsList}
|
||||
isLoading={isLoading}
|
||||
onDeleteClick={handleDeleteClick}
|
||||
accessToken={accessToken}
|
||||
onPluginUpdated={fetchPlugins}
|
||||
isAdmin={isAdmin}
|
||||
onPluginClick={(id) => setSelectedPluginId(id)}
|
||||
/>
|
||||
<>
|
||||
<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={handleCloseModal}
|
||||
onClose={() => setIsAddModalVisible(false)}
|
||||
accessToken={accessToken}
|
||||
onSuccess={handleSuccess}
|
||||
onSuccess={fetchPlugins}
|
||||
/>
|
||||
|
||||
{pluginToDelete && (
|
||||
<Modal
|
||||
title="Delete Plugin"
|
||||
title="Delete Skill"
|
||||
open={pluginToDelete !== null}
|
||||
onOk={handleDeleteConfirm}
|
||||
onCancel={handleDeleteCancel}
|
||||
onCancel={() => setPluginToDelete(null)}
|
||||
confirmLoading={isDeleting}
|
||||
okText="Delete"
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
<p>
|
||||
Are you sure you want to delete plugin:{" "}
|
||||
Are you sure you want to delete skill:{" "}
|
||||
<strong>{pluginToDelete.displayName}</strong>?
|
||||
</p>
|
||||
<p>This action cannot be undone.</p>
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
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;
|
||||
@@ -32,6 +32,80 @@ const PREDEFINED_CATEGORIES = [
|
||||
"Documentation",
|
||||
];
|
||||
|
||||
interface ParsedSource {
|
||||
source: "github" | "url" | "git-subdir";
|
||||
repo?: string;
|
||||
url?: string;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
interface ParsePreview {
|
||||
parsed: ParsedSource;
|
||||
label: string;
|
||||
suggestedName: string;
|
||||
}
|
||||
|
||||
function parseGitHubUrl(raw: string): ParsePreview | null {
|
||||
// Strip protocol and trailing slashes/spaces
|
||||
let s = raw.trim().replace(/^https?:\/\//, "").replace(/\/+$/, "");
|
||||
|
||||
if (!s.startsWith("github.com/")) return null;
|
||||
|
||||
// Remove "github.com/"
|
||||
const rest = s.slice("github.com/".length);
|
||||
const parts = rest.split("/");
|
||||
|
||||
if (parts.length < 2) return null;
|
||||
|
||||
const org = parts[0];
|
||||
const repo = parts[1];
|
||||
const repoBase = repo.replace(/\.git$/, "");
|
||||
|
||||
// github.com/org/repo (exactly 2 parts, or ends with .git)
|
||||
if (parts.length === 2 || (parts.length === 2 && repoBase)) {
|
||||
return {
|
||||
parsed: { source: "github", repo: `${org}/${repoBase}` },
|
||||
label: `GitHub repo — ${org}/${repoBase}`,
|
||||
suggestedName: repoBase,
|
||||
};
|
||||
}
|
||||
|
||||
// github.com/org/repo/tree/branch/folder or /blob/branch/folder/FILE.md
|
||||
if (
|
||||
parts.length >= 5 &&
|
||||
(parts[2] === "tree" || parts[2] === "blob")
|
||||
) {
|
||||
// parts[3] = branch, parts[4..] = path segments
|
||||
const pathParts = parts.slice(4);
|
||||
// If last segment looks like a file (has extension), drop it
|
||||
const lastPart = pathParts[pathParts.length - 1];
|
||||
if (lastPart && lastPart.includes(".")) {
|
||||
pathParts.pop();
|
||||
}
|
||||
if (pathParts.length === 0) {
|
||||
// Path resolved to repo root — treat as plain github source
|
||||
return {
|
||||
parsed: { source: "github", repo: `${org}/${repoBase}` },
|
||||
label: `GitHub repo — ${org}/${repoBase}`,
|
||||
suggestedName: repoBase,
|
||||
};
|
||||
}
|
||||
const subPath = pathParts.join("/");
|
||||
const suggestedName = pathParts[pathParts.length - 1];
|
||||
return {
|
||||
parsed: {
|
||||
source: "git-subdir",
|
||||
url: `https://github.com/${org}/${repoBase}`,
|
||||
path: subPath,
|
||||
},
|
||||
label: `GitHub subdir — ${org}/${repoBase} @ ${subPath}`,
|
||||
suggestedName,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const AddPluginForm: React.FC<AddPluginFormProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
@@ -40,7 +114,20 @@ const AddPluginForm: React.FC<AddPluginFormProps> = ({
|
||||
}) => {
|
||||
const [form] = Form.useForm();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [sourceType, setSourceType] = useState<"github" | "url" | "git-subdir">("github");
|
||||
const [urlPreview, setUrlPreview] = useState<ParsePreview | null>(null);
|
||||
|
||||
const handleUrlChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const val = e.target.value;
|
||||
const preview = parseGitHubUrl(val);
|
||||
setUrlPreview(preview);
|
||||
if (preview) {
|
||||
// Auto-fill name only if it's currently empty
|
||||
const currentName = form.getFieldValue("name");
|
||||
if (!currentName) {
|
||||
form.setFieldsValue({ name: preview.suggestedName });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (values: any) => {
|
||||
if (!accessToken) {
|
||||
@@ -48,98 +135,62 @@ const AddPluginForm: React.FC<AddPluginFormProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate plugin name
|
||||
if (!urlPreview) {
|
||||
MessageManager.error("Please enter a valid GitHub URL");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validatePluginName(values.name)) {
|
||||
MessageManager.error(
|
||||
"Plugin name must be kebab-case (lowercase letters, numbers, and hyphens only)"
|
||||
"Skill name must be kebab-case (lowercase letters, numbers, and hyphens only)"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate semantic version if provided
|
||||
if (values.version && !isValidSemanticVersion(values.version)) {
|
||||
MessageManager.error(
|
||||
"Version must be in semantic versioning format (e.g., 1.0.0)"
|
||||
);
|
||||
MessageManager.error("Version must be in semantic versioning format (e.g., 1.0.0)");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate email if provided
|
||||
if (values.authorEmail && !isValidEmail(values.authorEmail)) {
|
||||
MessageManager.error("Invalid email format");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate homepage URL if provided
|
||||
if (values.homepage && !isValidUrl(values.homepage)) {
|
||||
MessageManager.error("Invalid homepage URL format");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate git URL for url/git-subdir source types
|
||||
if ((sourceType === "url" || sourceType === "git-subdir") && values.url && !isValidUrl(values.url)) {
|
||||
MessageManager.error("Invalid git URL format");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
// Build plugin data
|
||||
const pluginData: any = {
|
||||
name: values.name.trim(),
|
||||
source:
|
||||
sourceType === "github"
|
||||
? {
|
||||
source: "github",
|
||||
repo: values.repo.trim(),
|
||||
}
|
||||
: sourceType === "git-subdir"
|
||||
? {
|
||||
source: "git-subdir",
|
||||
url: values.url.trim(),
|
||||
path: values.path.trim(),
|
||||
}
|
||||
: {
|
||||
source: "url",
|
||||
url: values.url.trim(),
|
||||
},
|
||||
source: urlPreview.parsed,
|
||||
};
|
||||
|
||||
// Add optional fields
|
||||
if (values.version) {
|
||||
pluginData.version = values.version.trim();
|
||||
}
|
||||
if (values.description) {
|
||||
pluginData.description = values.description.trim();
|
||||
}
|
||||
if (values.version) pluginData.version = values.version.trim();
|
||||
if (values.description) pluginData.description = values.description.trim();
|
||||
if (values.authorName || values.authorEmail) {
|
||||
pluginData.author = {};
|
||||
if (values.authorName) {
|
||||
pluginData.author.name = values.authorName.trim();
|
||||
}
|
||||
if (values.authorEmail) {
|
||||
pluginData.author.email = values.authorEmail.trim();
|
||||
}
|
||||
}
|
||||
if (values.homepage) {
|
||||
pluginData.homepage = values.homepage.trim();
|
||||
}
|
||||
if (values.category) {
|
||||
pluginData.category = values.category;
|
||||
}
|
||||
if (values.keywords) {
|
||||
pluginData.keywords = parseKeywords(values.keywords);
|
||||
if (values.authorName) pluginData.author.name = values.authorName.trim();
|
||||
if (values.authorEmail) pluginData.author.email = values.authorEmail.trim();
|
||||
}
|
||||
if (values.homepage) pluginData.homepage = values.homepage.trim();
|
||||
if (values.category) pluginData.category = values.category;
|
||||
if (values.keywords) pluginData.keywords = parseKeywords(values.keywords);
|
||||
if (values.domain) pluginData.domain = values.domain.trim();
|
||||
if (values.namespace) pluginData.namespace = values.namespace.trim();
|
||||
|
||||
await registerClaudeCodePlugin(accessToken, pluginData);
|
||||
MessageManager.success("Plugin registered successfully");
|
||||
MessageManager.success("Skill registered successfully");
|
||||
form.resetFields();
|
||||
setSourceType("github");
|
||||
setUrlPreview(null);
|
||||
onSuccess();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Error registering plugin:", error);
|
||||
MessageManager.error("Failed to register plugin");
|
||||
console.error("Error registering skill:", error);
|
||||
MessageManager.error("Failed to register skill");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
@@ -147,19 +198,13 @@ const AddPluginForm: React.FC<AddPluginFormProps> = ({
|
||||
|
||||
const handleCancel = () => {
|
||||
form.resetFields();
|
||||
setSourceType("github");
|
||||
setUrlPreview(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleSourceTypeChange = (value: "github" | "url" | "git-subdir") => {
|
||||
setSourceType(value);
|
||||
// Clear repo/url/path fields when switching
|
||||
form.setFieldsValue({ repo: undefined, url: undefined, path: undefined });
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Add New Claude Code Plugin"
|
||||
title="Add New Skill"
|
||||
open={visible}
|
||||
onCancel={handleCancel}
|
||||
footer={null}
|
||||
@@ -172,111 +217,72 @@ const AddPluginForm: React.FC<AddPluginFormProps> = ({
|
||||
onFinish={handleSubmit}
|
||||
className="mt-4"
|
||||
>
|
||||
{/* Plugin Name */}
|
||||
{/* Smart URL Input */}
|
||||
<Form.Item
|
||||
label="Plugin Name"
|
||||
label="GitHub URL"
|
||||
name="skillUrl"
|
||||
rules={[{ required: true, message: "Please enter a GitHub URL" }]}
|
||||
tooltip="Paste a GitHub URL — repo, folder, or file link. E.g. github.com/org/repo or github.com/org/repo/tree/main/my-skill"
|
||||
>
|
||||
<Input
|
||||
placeholder="https://github.com/org/repo/tree/main/my-skill"
|
||||
className="rounded-lg"
|
||||
onChange={handleUrlChange}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{/* Parsed preview */}
|
||||
{urlPreview && (
|
||||
<div className="mb-4 px-3 py-2 bg-blue-50 border border-blue-200 rounded-lg text-sm text-blue-700">
|
||||
Detected: {urlPreview.label}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Skill Name */}
|
||||
<Form.Item
|
||||
label="Skill Name"
|
||||
name="name"
|
||||
rules={[
|
||||
{ required: true, message: "Please enter plugin name" },
|
||||
{ required: true, message: "Please enter skill name" },
|
||||
{
|
||||
pattern: /^[a-z0-9-]+$/,
|
||||
message:
|
||||
"Name must be kebab-case (lowercase, numbers, hyphens only)",
|
||||
message: "Name must be kebab-case (lowercase, numbers, hyphens only)",
|
||||
},
|
||||
]}
|
||||
tooltip="Unique identifier in kebab-case format (e.g., my-awesome-plugin)"
|
||||
tooltip="Unique identifier in kebab-case format (e.g., my-skill)"
|
||||
>
|
||||
<Input placeholder="my-awesome-plugin" className="rounded-lg" />
|
||||
<Input placeholder="my-skill" className="rounded-lg" />
|
||||
</Form.Item>
|
||||
|
||||
{/* Source Type */}
|
||||
<Form.Item
|
||||
label="Source Type"
|
||||
name="sourceType"
|
||||
initialValue="github"
|
||||
rules={[{ required: true, message: "Please select source type" }]}
|
||||
>
|
||||
<Select onChange={handleSourceTypeChange} className="rounded-lg">
|
||||
<Option value="github">GitHub</Option>
|
||||
<Option value="url">Git URL</Option>
|
||||
<Option value="git-subdir">Git Subdir</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
{/* GitHub Repository */}
|
||||
{sourceType === "github" && (
|
||||
{/* Domain and Namespace — side by side */}
|
||||
<div className="flex gap-4">
|
||||
<Form.Item
|
||||
label="GitHub Repository"
|
||||
name="repo"
|
||||
rules={[
|
||||
{ required: true, message: "Please enter repository" },
|
||||
{
|
||||
pattern: /^[a-zA-Z0-9_-]+\/[a-zA-Z0-9_-]+$/,
|
||||
message: "Repository must be in format: org/repo",
|
||||
},
|
||||
]}
|
||||
tooltip="Format: organization/repository (e.g., anthropics/claude-code)"
|
||||
label="Domain (Optional)"
|
||||
name="domain"
|
||||
tooltip="Top-level grouping in the Skill Hub (e.g., Productivity)"
|
||||
className="flex-1"
|
||||
>
|
||||
<Input placeholder="anthropics/claude-code" className="rounded-lg" />
|
||||
<Input placeholder="Productivity" className="rounded-lg" />
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{/* Git URL */}
|
||||
{(sourceType === "url" || sourceType === "git-subdir") && (
|
||||
<Form.Item
|
||||
label="Git URL"
|
||||
name="url"
|
||||
rules={[{ required: true, message: "Please enter git URL" }]}
|
||||
tooltip="Full git URL to the repository"
|
||||
label="Namespace (Optional)"
|
||||
name="namespace"
|
||||
tooltip="Sub-grouping within domain (e.g., workflows)"
|
||||
className="flex-1"
|
||||
>
|
||||
<Input
|
||||
type="url"
|
||||
placeholder="https://github.com/org/repo.git"
|
||||
className="rounded-lg"
|
||||
/>
|
||||
<Input placeholder="workflows" className="rounded-lg" />
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{/* Git Subdir Path */}
|
||||
{sourceType === "git-subdir" && (
|
||||
<Form.Item
|
||||
label="Subdirectory Path"
|
||||
name="path"
|
||||
rules={[
|
||||
{ required: true, message: "Please enter subdirectory path" },
|
||||
{
|
||||
pattern: /^[a-zA-Z0-9][a-zA-Z0-9._-]*(\/[a-zA-Z0-9][a-zA-Z0-9._-]*)*$/,
|
||||
message:
|
||||
"Path must be relative segments (alphanumeric, dots, hyphens, underscores), e.g. plugins/plugin-name",
|
||||
},
|
||||
]}
|
||||
tooltip="Path to the plugin directory within the repository (e.g., plugins/plugin-name)"
|
||||
>
|
||||
<Input
|
||||
placeholder="plugins/plugin-name"
|
||||
className="rounded-lg"
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{/* Version */}
|
||||
<Form.Item
|
||||
label="Version (Optional)"
|
||||
name="version"
|
||||
tooltip="Semantic version (e.g., 1.0.0)"
|
||||
>
|
||||
<Input placeholder="1.0.0" className="rounded-lg" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<Form.Item
|
||||
label="Description (Optional)"
|
||||
name="description"
|
||||
tooltip="Brief description of what the plugin does"
|
||||
tooltip="Brief description of what the skill does"
|
||||
>
|
||||
<TextArea
|
||||
rows={3}
|
||||
placeholder="A plugin that helps with..."
|
||||
placeholder="A skill that helps with..."
|
||||
maxLength={500}
|
||||
className="rounded-lg"
|
||||
/>
|
||||
@@ -312,11 +318,20 @@ const AddPluginForm: React.FC<AddPluginFormProps> = ({
|
||||
<Input placeholder="search, web, api" className="rounded-lg" />
|
||||
</Form.Item>
|
||||
|
||||
{/* Version */}
|
||||
<Form.Item
|
||||
label="Version (Optional)"
|
||||
name="version"
|
||||
tooltip="Semantic version (e.g., 1.0.0)"
|
||||
>
|
||||
<Input placeholder="1.0.0" className="rounded-lg" />
|
||||
</Form.Item>
|
||||
|
||||
{/* Author Name */}
|
||||
<Form.Item
|
||||
label="Author Name (Optional)"
|
||||
name="authorName"
|
||||
tooltip="Name of the plugin author or organization"
|
||||
tooltip="Name of the skill author or organization"
|
||||
>
|
||||
<Input placeholder="Your Name or Organization" className="rounded-lg" />
|
||||
</Form.Item>
|
||||
@@ -326,33 +341,19 @@ const AddPluginForm: React.FC<AddPluginFormProps> = ({
|
||||
label="Author Email (Optional)"
|
||||
name="authorEmail"
|
||||
rules={[{ type: "email", message: "Please enter a valid email" }]}
|
||||
tooltip="Contact email for the plugin author"
|
||||
tooltip="Contact email for the skill author"
|
||||
>
|
||||
<Input type="email" placeholder="author@example.com" className="rounded-lg" />
|
||||
</Form.Item>
|
||||
|
||||
{/* Homepage */}
|
||||
<Form.Item
|
||||
label="Homepage (Optional)"
|
||||
name="homepage"
|
||||
rules={[{ type: "url", message: "Please enter a valid URL" }]}
|
||||
tooltip="URL to the plugin's homepage or documentation"
|
||||
>
|
||||
<Input type="url" placeholder="https://example.com" className="rounded-lg" />
|
||||
</Form.Item>
|
||||
|
||||
{/* Submit Buttons */}
|
||||
<Form.Item className="mb-0 mt-6">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleCancel}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<Button variant="secondary" onClick={handleCancel} disabled={isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" loading={isSubmitting}>
|
||||
{isSubmitting ? "Registering..." : "Register Plugin"}
|
||||
{isSubmitting ? "Adding..." : "Add Skill"}
|
||||
</Button>
|
||||
</div>
|
||||
</Form.Item>
|
||||
|
||||
@@ -23,13 +23,9 @@ import {
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
} from "@tremor/react";
|
||||
import { Switch, Tooltip } from "antd";
|
||||
import { Tooltip } from "antd";
|
||||
import React, { useState } from "react";
|
||||
import NotificationsManager from "../molecules/notifications_manager";
|
||||
import {
|
||||
disableClaudeCodePlugin,
|
||||
enableClaudeCodePlugin,
|
||||
} from "../networking";
|
||||
import {
|
||||
getCategoryBadgeColor
|
||||
} from "./helpers";
|
||||
@@ -40,7 +36,6 @@ interface PluginTableProps {
|
||||
isLoading: boolean;
|
||||
onDeleteClick: (pluginName: string, displayName: string) => void;
|
||||
accessToken: string | null;
|
||||
onPluginUpdated: () => void;
|
||||
isAdmin: boolean;
|
||||
onPluginClick: (pluginId: string) => void;
|
||||
}
|
||||
@@ -50,14 +45,12 @@ const PluginTable: React.FC<PluginTableProps> = ({
|
||||
isLoading,
|
||||
onDeleteClick,
|
||||
accessToken,
|
||||
onPluginUpdated,
|
||||
isAdmin,
|
||||
onPluginClick,
|
||||
}) => {
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{ id: "created_at", desc: true },
|
||||
]);
|
||||
const [togglingPlugin, setTogglingPlugin] = useState<string | null>(null);
|
||||
|
||||
const formatDate = (dateString?: string) => {
|
||||
if (!dateString) return "-";
|
||||
@@ -70,29 +63,9 @@ const PluginTable: React.FC<PluginTableProps> = ({
|
||||
NotificationsManager.success("Copied to clipboard!");
|
||||
};
|
||||
|
||||
const handleToggleEnabled = async (plugin: Plugin) => {
|
||||
if (!accessToken) return;
|
||||
|
||||
setTogglingPlugin(plugin.id);
|
||||
try {
|
||||
if (plugin.enabled) {
|
||||
await disableClaudeCodePlugin(accessToken, plugin.name);
|
||||
NotificationsManager.success(`Plugin "${plugin.name}" disabled`);
|
||||
} else {
|
||||
await enableClaudeCodePlugin(accessToken, plugin.name);
|
||||
NotificationsManager.success(`Plugin "${plugin.name}" enabled`);
|
||||
}
|
||||
onPluginUpdated();
|
||||
} catch (error) {
|
||||
NotificationsManager.error("Failed to toggle plugin status");
|
||||
} finally {
|
||||
setTogglingPlugin(null);
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnDef<Plugin>[] = [
|
||||
{
|
||||
header: "Plugin Name",
|
||||
header: "Skill Name",
|
||||
accessorKey: "name",
|
||||
cell: ({ row }) => {
|
||||
const plugin = row.original;
|
||||
@@ -165,32 +138,18 @@ const PluginTable: React.FC<PluginTableProps> = ({
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Enabled",
|
||||
header: "Public",
|
||||
accessorKey: "enabled",
|
||||
cell: ({ row }) => {
|
||||
const plugin = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
color={plugin.enabled ? "green" : "gray"}
|
||||
className="text-xs font-normal"
|
||||
size="xs"
|
||||
>
|
||||
{plugin.enabled ? "Yes" : "No"}
|
||||
</Badge>
|
||||
{isAdmin && (
|
||||
<Tooltip
|
||||
title={plugin.enabled ? "Disable plugin" : "Enable plugin"}
|
||||
>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={plugin.enabled}
|
||||
loading={togglingPlugin === plugin.id}
|
||||
onChange={() => handleToggleEnabled(plugin)}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<Badge
|
||||
color={plugin.enabled ? "green" : "gray"}
|
||||
className="text-xs font-normal"
|
||||
size="xs"
|
||||
>
|
||||
{plugin.enabled ? "Yes" : "No"}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
@@ -217,7 +176,7 @@ const PluginTable: React.FC<PluginTableProps> = ({
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<Tooltip title="Delete plugin">
|
||||
<Tooltip title="Delete skill">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
@@ -312,7 +271,11 @@ const PluginTable: React.FC<PluginTableProps> = ({
|
||||
</TableRow>
|
||||
) : pluginsList && pluginsList.length > 0 ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id} className="h-8">
|
||||
<TableRow
|
||||
key={row.id}
|
||||
className="h-8 cursor-pointer hover:bg-gray-50"
|
||||
onClick={() => onPluginClick(row.original.id)}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
@@ -333,7 +296,7 @@ const PluginTable: React.FC<PluginTableProps> = ({
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-8 text-center">
|
||||
<div className="text-center text-gray-500">
|
||||
<p>No plugins found. Add one to get started.</p>
|
||||
<p>No skills found. Add one to get started.</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
import React, { useState } from "react";
|
||||
import { ArrowLeftOutlined, CopyOutlined, CheckOutlined, LinkOutlined } from "@ant-design/icons";
|
||||
import { formatInstallCommand } from "./helpers";
|
||||
import { Plugin } from "./types";
|
||||
|
||||
interface SkillDetailProps {
|
||||
skill: Plugin;
|
||||
onBack: () => void;
|
||||
isAdmin?: boolean;
|
||||
accessToken?: string | null;
|
||||
onPublishClick?: () => void;
|
||||
}
|
||||
|
||||
const SkillDetail: React.FC<SkillDetailProps> = ({
|
||||
skill,
|
||||
onBack,
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState("overview");
|
||||
const [copiedKey, setCopiedKey] = useState<string | null>(null);
|
||||
|
||||
const copyToClipboard = (text: string, key: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopiedKey(key);
|
||||
setTimeout(() => setCopiedKey(null), 2000);
|
||||
};
|
||||
|
||||
const sourceUrl = (() => {
|
||||
const src = skill.source;
|
||||
if (src.source === "github" && src.repo) return `https://github.com/${src.repo}`;
|
||||
if (src.source === "git-subdir" && src.url)
|
||||
return src.path ? `${src.url}/tree/main/${src.path}` : src.url;
|
||||
if (src.source === "url" && src.url) return src.url;
|
||||
return null;
|
||||
})();
|
||||
|
||||
const installCommand = formatInstallCommand(skill);
|
||||
|
||||
const detailRows = [
|
||||
...(skill.category ? [{ property: "Category", value: skill.category }] : []),
|
||||
...(skill.domain ? [{ property: "Domain", value: skill.domain }] : []),
|
||||
...(skill.namespace ? [{ property: "Namespace", value: skill.namespace }] : []),
|
||||
...(skill.version ? [{ property: "Version", value: skill.version }] : []),
|
||||
...(skill.author?.name ? [{ property: "Author", value: skill.author.name }] : []),
|
||||
...(skill.created_at
|
||||
? [{ property: "Added", value: new Date(skill.created_at).toLocaleDateString() }]
|
||||
: []),
|
||||
];
|
||||
|
||||
const tabs = [
|
||||
{ key: "overview", label: "Overview" },
|
||||
{ key: "usage", label: "How to Use" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: "24px 32px 24px 0" }}>
|
||||
{/* Back link */}
|
||||
<div
|
||||
onClick={onBack}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
color: "#5f6368",
|
||||
cursor: "pointer",
|
||||
fontSize: 14,
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
<ArrowLeftOutlined style={{ fontSize: 11 }} />
|
||||
<span>Skills</span>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<h1 style={{ fontSize: 28, fontWeight: 400, color: "#202124", margin: 0, lineHeight: 1.2 }}>
|
||||
{skill.name}
|
||||
</h1>
|
||||
{skill.description && (
|
||||
<p style={{ fontSize: 14, color: "#5f6368", margin: "8px 0 0 0", lineHeight: 1.6 }}>
|
||||
{skill.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tab bar */}
|
||||
<div style={{ borderBottom: "1px solid #dadce0", marginBottom: 28, marginTop: 24 }}>
|
||||
<div style={{ display: "flex", gap: 0 }}>
|
||||
{tabs.map((tab) => (
|
||||
<div
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
style={{
|
||||
padding: "12px 20px",
|
||||
fontSize: 14,
|
||||
color: activeTab === tab.key ? "#1a73e8" : "#5f6368",
|
||||
borderBottom: activeTab === tab.key ? "3px solid #1a73e8" : "3px solid transparent",
|
||||
cursor: "pointer",
|
||||
fontWeight: activeTab === tab.key ? 500 : 400,
|
||||
marginBottom: -1,
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Overview tab */}
|
||||
{activeTab === "overview" && (
|
||||
<div style={{ display: "flex", gap: 64 }}>
|
||||
{/* Left column */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<h2 style={{ fontSize: 18, fontWeight: 400, color: "#202124", margin: "0 0 4px 0" }}>
|
||||
Skill Details
|
||||
</h2>
|
||||
<p style={{ fontSize: 13, color: "#5f6368", margin: "0 0 16px 0" }}>
|
||||
Metadata registered with this skill
|
||||
</p>
|
||||
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 14 }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: "1px solid #dadce0" }}>
|
||||
<th style={{ textAlign: "left", padding: "12px 0", color: "#5f6368", fontWeight: 500, width: 160 }}>
|
||||
Property
|
||||
</th>
|
||||
<th style={{ textAlign: "left", padding: "12px 0", color: "#5f6368", fontWeight: 500 }}>
|
||||
{skill.name}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{detailRows.map((row, i) => (
|
||||
<tr key={i} style={{ borderBottom: "1px solid #f1f3f4" }}>
|
||||
<td style={{ padding: "12px 0", color: "#3c4043" }}>{row.property}</td>
|
||||
<td style={{ padding: "12px 0", color: "#202124" }}>{row.value}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Right sidebar */}
|
||||
<div style={{ width: 240, flexShrink: 0 }}>
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<div style={{ fontSize: 12, color: "#5f6368", marginBottom: 4 }}>Status</div>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 12,
|
||||
padding: "3px 10px",
|
||||
borderRadius: 12,
|
||||
backgroundColor: skill.enabled ? "#e6f4ea" : "#f1f3f4",
|
||||
color: skill.enabled ? "#137333" : "#5f6368",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{skill.enabled ? "Public" : "Draft"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{sourceUrl && (
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<div style={{ fontSize: 12, color: "#5f6368", marginBottom: 4 }}>Source</div>
|
||||
<a
|
||||
href={sourceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ fontSize: 13, color: "#1a73e8", wordBreak: "break-all", display: "flex", alignItems: "center", gap: 4 }}
|
||||
>
|
||||
{sourceUrl.replace("https://", "")}
|
||||
<LinkOutlined style={{ fontSize: 11, flexShrink: 0 }} />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{skill.keywords && skill.keywords.length > 0 && (
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<div style={{ fontSize: 12, color: "#5f6368", marginBottom: 8 }}>Tags</div>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
|
||||
{skill.keywords.map((kw) => (
|
||||
<span
|
||||
key={kw}
|
||||
style={{
|
||||
fontSize: 12,
|
||||
padding: "4px 12px",
|
||||
borderRadius: 16,
|
||||
border: "1px solid #dadce0",
|
||||
color: "#3c4043",
|
||||
backgroundColor: "#fff",
|
||||
}}
|
||||
>
|
||||
{kw}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: "#5f6368", marginBottom: 4 }}>Skill ID</div>
|
||||
<div style={{ fontSize: 12, fontFamily: "monospace", color: "#3c4043", wordBreak: "break-all" }}>
|
||||
{skill.id}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* How to Use tab */}
|
||||
{activeTab === "usage" && (
|
||||
<div style={{ maxWidth: 640 }}>
|
||||
<h2 style={{ fontSize: 18, fontWeight: 400, color: "#202124", margin: "0 0 8px 0" }}>
|
||||
Using this skill
|
||||
</h2>
|
||||
<p style={{ fontSize: 14, color: "#5f6368", margin: "0 0 24px 0", lineHeight: 1.6 }}>
|
||||
Once your proxy is set as a marketplace, enable this skill in Claude Code with one command:
|
||||
</p>
|
||||
|
||||
{/* Install command */}
|
||||
<div
|
||||
style={{
|
||||
border: "1px solid #dadce0",
|
||||
borderRadius: 8,
|
||||
overflow: "hidden",
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "10px 16px",
|
||||
backgroundColor: "#f8f9fa",
|
||||
borderBottom: "1px solid #dadce0",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 13, color: "#3c4043", fontWeight: 500 }}>
|
||||
Run in Claude Code
|
||||
</span>
|
||||
<button
|
||||
onClick={() => copyToClipboard(installCommand, "install")}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
fontSize: 12,
|
||||
color: copiedKey === "install" ? "#137333" : "#1a73e8",
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
{copiedKey === "install" ? <CheckOutlined /> : <CopyOutlined />}
|
||||
{copiedKey === "install" ? "Copied" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
<pre
|
||||
style={{
|
||||
margin: 0,
|
||||
padding: "14px 16px",
|
||||
fontSize: 14,
|
||||
fontFamily: "monospace",
|
||||
color: "#202124",
|
||||
backgroundColor: "#fff",
|
||||
}}
|
||||
>
|
||||
{installCommand}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<p style={{ fontSize: 13, color: "#5f6368", lineHeight: 1.6, margin: 0 }}>
|
||||
Don't have the marketplace configured yet?{" "}
|
||||
<span
|
||||
onClick={() => setActiveTab("setup")}
|
||||
style={{ color: "#1a73e8", cursor: "pointer" }}
|
||||
>
|
||||
See one-time setup →
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Setup tab (linked from usage) */}
|
||||
{activeTab === "setup" && (
|
||||
<div style={{ maxWidth: 640 }}>
|
||||
<h2 style={{ fontSize: 18, fontWeight: 400, color: "#202124", margin: "0 0 8px 0" }}>
|
||||
One-time marketplace setup
|
||||
</h2>
|
||||
<p style={{ fontSize: 14, color: "#5f6368", margin: "0 0 24px 0", lineHeight: 1.6 }}>
|
||||
Add this to <code style={{ fontSize: 13, backgroundColor: "#f1f3f4", padding: "1px 6px", borderRadius: 4 }}>~/.claude/settings.json</code> to point Claude Code at your proxy:
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
border: "1px solid #dadce0",
|
||||
borderRadius: 8,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "10px 16px",
|
||||
backgroundColor: "#f8f9fa",
|
||||
borderBottom: "1px solid #dadce0",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 13, color: "#3c4043", fontWeight: 500 }}>
|
||||
~/.claude/settings.json
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
const snippet = JSON.stringify(
|
||||
{ extraKnownMarketplaces: { "my-org": { source: "url", url: `${typeof window !== "undefined" ? window.location.origin : ""}/claude-code/marketplace.json` } } },
|
||||
null, 2
|
||||
);
|
||||
copyToClipboard(snippet, "settings");
|
||||
}}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
fontSize: 12,
|
||||
color: copiedKey === "settings" ? "#137333" : "#1a73e8",
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
{copiedKey === "settings" ? <CheckOutlined /> : <CopyOutlined />}
|
||||
{copiedKey === "settings" ? "Copied" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
<pre
|
||||
style={{
|
||||
margin: 0,
|
||||
padding: "14px 16px",
|
||||
fontSize: 13,
|
||||
fontFamily: "monospace",
|
||||
color: "#202124",
|
||||
backgroundColor: "#fff",
|
||||
}}
|
||||
>
|
||||
{JSON.stringify(
|
||||
{
|
||||
extraKnownMarketplaces: {
|
||||
"my-org": {
|
||||
source: "url",
|
||||
url: `${typeof window !== "undefined" ? window.location.origin : "<proxy-url>"}/claude-code/marketplace.json`,
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SkillDetail;
|
||||
@@ -4,9 +4,10 @@
|
||||
*/
|
||||
|
||||
export interface PluginSource {
|
||||
source: "github" | "url";
|
||||
source: "github" | "url" | "git-subdir";
|
||||
repo?: string; // Format: "org/repo" for GitHub
|
||||
url?: string; // Full URL for other sources
|
||||
path?: string; // Subdirectory path for git-subdir
|
||||
}
|
||||
|
||||
export interface PluginAuthor {
|
||||
@@ -24,6 +25,8 @@ export interface Plugin {
|
||||
homepage?: string;
|
||||
keywords?: string[];
|
||||
category?: string;
|
||||
domain?: string;
|
||||
namespace?: string;
|
||||
enabled: boolean;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
@@ -40,6 +43,8 @@ export interface PluginListItem {
|
||||
homepage?: string;
|
||||
keywords?: string[];
|
||||
category?: string;
|
||||
domain?: string;
|
||||
namespace?: string;
|
||||
enabled: boolean;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
@@ -60,6 +65,8 @@ export interface RegisterPluginRequest {
|
||||
homepage?: string;
|
||||
keywords?: string[];
|
||||
category?: string;
|
||||
domain?: string;
|
||||
namespace?: string;
|
||||
}
|
||||
|
||||
export interface RegisterPluginResponse {
|
||||
@@ -100,9 +107,10 @@ export interface CategoryTab {
|
||||
|
||||
export interface PluginFormData {
|
||||
name: string;
|
||||
sourceType: "github" | "url";
|
||||
sourceType: "github" | "url" | "git-subdir";
|
||||
repo: string;
|
||||
url: string;
|
||||
path: string;
|
||||
version: string;
|
||||
description: string;
|
||||
authorName: string;
|
||||
@@ -110,4 +118,6 @@ export interface PluginFormData {
|
||||
homepage: string;
|
||||
category: string;
|
||||
keywords: string; // Comma-separated string, will be split into array
|
||||
domain: string;
|
||||
namespace: string;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ const BudgetDurationDropdown: React.FC<BudgetDurationDropdownProps> = ({
|
||||
placeholder="n/a"
|
||||
allowClear
|
||||
>
|
||||
<Option value="1h">hourly</Option>
|
||||
<Option value="24h">daily</Option>
|
||||
<Option value="7d">weekly</Option>
|
||||
<Option value="30d">monthly</Option>
|
||||
@@ -36,6 +37,7 @@ export const getBudgetDurationLabel = (value: string | null | undefined): string
|
||||
if (!value) return "Not set";
|
||||
|
||||
const budgetDurationMap: Record<string, string> = {
|
||||
"1h": "hourly",
|
||||
"24h": "daily",
|
||||
"7d": "weekly",
|
||||
"30d": "monthly",
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Button, InputNumber, Select } from "antd";
|
||||
import React from "react";
|
||||
|
||||
export interface BudgetWindowEntry {
|
||||
budget_duration: string;
|
||||
max_budget: number | null;
|
||||
}
|
||||
|
||||
export const BUDGET_WINDOW_OPTIONS = [
|
||||
{ value: "1h", label: "Hourly", resetHint: "Resets every hour" },
|
||||
{ value: "24h", label: "Daily", resetHint: "Resets daily at midnight UTC" },
|
||||
{ value: "7d", label: "Weekly", resetHint: "Resets every Sunday at midnight UTC" },
|
||||
{ value: "30d", label: "Monthly", resetHint: "Resets on the 1st of every month at midnight UTC" },
|
||||
];
|
||||
|
||||
interface BudgetWindowsEditorProps {
|
||||
value: BudgetWindowEntry[];
|
||||
onChange: (v: BudgetWindowEntry[]) => void;
|
||||
}
|
||||
|
||||
export function BudgetWindowsEditor({ value, onChange }: BudgetWindowsEditorProps) {
|
||||
const addWindow = () => {
|
||||
onChange([...value, { budget_duration: "24h", max_budget: null }]);
|
||||
};
|
||||
|
||||
const removeWindow = (idx: number) => {
|
||||
onChange(value.filter((_, i) => i !== idx));
|
||||
};
|
||||
|
||||
const updateWindow = (idx: number, field: keyof BudgetWindowEntry, fieldValue: string | number | null) => {
|
||||
const updated = value.map((w, i) => (i === idx ? { ...w, [field]: fieldValue } : w));
|
||||
onChange(updated);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{value.map((window, idx) => {
|
||||
const hint = BUDGET_WINDOW_OPTIONS.find((o) => o.value === window.budget_duration)?.resetHint;
|
||||
return (
|
||||
<div key={idx} style={{ marginBottom: 12 }}>
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||
<Select
|
||||
value={window.budget_duration}
|
||||
onChange={(v) => updateWindow(idx, "budget_duration", v)}
|
||||
style={{ width: 130 }}
|
||||
options={BUDGET_WINDOW_OPTIONS.map((o) => ({ value: o.value, label: o.label }))}
|
||||
/>
|
||||
<InputNumber
|
||||
step={0.01}
|
||||
min={0}
|
||||
precision={2}
|
||||
value={window.max_budget ?? undefined}
|
||||
onChange={(v) => updateWindow(idx, "max_budget", v ?? null)}
|
||||
placeholder="Max spend ($)"
|
||||
style={{ width: 160 }}
|
||||
prefix="$"
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
size="small"
|
||||
onClick={() => removeWindow(idx)}
|
||||
style={{ padding: "0 4px" }}
|
||||
>
|
||||
✕
|
||||
</Button>
|
||||
</div>
|
||||
{hint && (
|
||||
<div style={{ fontSize: 11, color: "#888", marginTop: 3, marginLeft: 2 }}>
|
||||
↻ {hint}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<Button
|
||||
size="small"
|
||||
onClick={(e) => { e.preventDefault(); addWindow(); }}
|
||||
>
|
||||
+ Add Budget Window
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -95,6 +95,7 @@ export interface KeyResponse {
|
||||
agent_access_groups?: string[];
|
||||
};
|
||||
access_group_ids?: string[];
|
||||
budget_limits?: Array<{ budget_duration: string; max_budget: number; reset_at?: string }>;
|
||||
auto_rotate?: boolean;
|
||||
rotation_interval?: string;
|
||||
last_rotation_at?: string;
|
||||
|
||||
@@ -132,6 +132,13 @@ const menuGroups: MenuGroup[] = [
|
||||
label: "MCP Servers",
|
||||
icon: <ToolOutlined />,
|
||||
},
|
||||
{
|
||||
key: "skills",
|
||||
page: "skills",
|
||||
label: "Skills",
|
||||
icon: <ApiOutlined />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
{
|
||||
key: "guardrails",
|
||||
page: "guardrails",
|
||||
@@ -267,6 +274,7 @@ const menuGroups: MenuGroup[] = [
|
||||
label: "AI Hub",
|
||||
icon: <AppstoreOutlined />,
|
||||
},
|
||||
|
||||
{
|
||||
key: "learning-resources",
|
||||
page: "learning-resources",
|
||||
@@ -308,13 +316,6 @@ const menuGroups: MenuGroup[] = [
|
||||
icon: <TagsOutlined />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
{
|
||||
key: "claude-code-plugins",
|
||||
page: "claude-code-plugins",
|
||||
label: "Claude Code Plugins",
|
||||
icon: <ToolOutlined />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
{
|
||||
key: "4",
|
||||
page: "usage",
|
||||
|
||||
@@ -81,7 +81,9 @@ const isLocal = process.env.NODE_ENV === "development";
|
||||
// In dev, if NEXT_PUBLIC_USE_REWRITES=true the Next.js dev server proxies API calls
|
||||
// to the backend — use relative URLs (null) so rewrites can intercept them.
|
||||
const defaultProxyBaseUrl =
|
||||
isLocal && process.env.NEXT_PUBLIC_USE_REWRITES !== "true"
|
||||
process.env.NEXT_PUBLIC_BASE_URL
|
||||
? process.env.NEXT_PUBLIC_BASE_URL
|
||||
: isLocal && process.env.NEXT_PUBLIC_USE_REWRITES !== "true"
|
||||
? "http://localhost:4000"
|
||||
: null;
|
||||
const defaultServerRootPath = "/";
|
||||
@@ -123,10 +125,11 @@ const updateProxyBaseUrl = (serverRootPath: string, receivedProxyBaseUrl: string
|
||||
return;
|
||||
}
|
||||
const browserLocation = getWindowLocation();
|
||||
const resolvedDefaultProxyBaseUrl =
|
||||
isLocal && process.env.NEXT_PUBLIC_USE_REWRITES !== "true"
|
||||
? "http://localhost:4000"
|
||||
: browserLocation?.origin ?? null;
|
||||
const resolvedDefaultProxyBaseUrl = process.env.NEXT_PUBLIC_BASE_URL
|
||||
? process.env.NEXT_PUBLIC_BASE_URL
|
||||
: isLocal && process.env.NEXT_PUBLIC_USE_REWRITES !== "true"
|
||||
? "http://localhost:4000"
|
||||
: browserLocation?.origin ?? null;
|
||||
let initialProxyBaseUrl = receivedProxyBaseUrl || resolvedDefaultProxyBaseUrl;
|
||||
console.log("proxyBaseUrl:", proxyBaseUrl);
|
||||
console.log("serverRootPath:", serverRootPath);
|
||||
@@ -2278,6 +2281,19 @@ export const mcpHubPublicServersCall = async () => {
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const skillHubPublicCall = async () => {
|
||||
const url = proxyBaseUrl ? `${proxyBaseUrl}/public/skill_hub` : `/public/skill_hub`;
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
if (!response.ok) {
|
||||
console.error(`skillHubPublicCall failed with status ${response.status}`);
|
||||
return { plugins: [] };
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const modelHubCall = async (accessToken: string) => {
|
||||
/**
|
||||
* Get all models on proxy
|
||||
|
||||
@@ -9,7 +9,7 @@ import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Accordion, AccordionBody, AccordionHeader, Button, Col, Grid, Text, TextInput, Title } from "@tremor/react";
|
||||
import { Button as Button2, Form, Input, Modal, Radio, Select, Switch, Tag, Tooltip } from "antd";
|
||||
import { Button as Button2, Form, Input, InputNumber, Modal, Radio, Select, Switch, Tag, Tooltip } from "antd";
|
||||
import debounce from "lodash/debounce";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { rolesWithWriteAccess } from "../../utils/roles";
|
||||
@@ -28,6 +28,7 @@ import TeamDropdown from "../common_components/team_dropdown";
|
||||
import OrganizationDropdown from "../common_components/OrganizationDropdown";
|
||||
import ProjectDropdown from "../common_components/ProjectDropdown";
|
||||
import { CreateUserButton } from "../CreateUserButton";
|
||||
import { BudgetWindowEntry, BudgetWindowsEditor } from "../key_team_helpers/BudgetWindowsEditor";
|
||||
import { getModelDisplayName } from "../key_team_helpers/fetch_available_models_team_key";
|
||||
import { Team } from "../key_team_helpers/key_list";
|
||||
import MCPServerSelector from "../mcp_server_management/MCPServerSelector";
|
||||
@@ -153,6 +154,7 @@ export const fetchUserModels = async (
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* ─────────────────────────────────────────────────────────────────────────
|
||||
* @deprecated
|
||||
@@ -201,6 +203,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
||||
const [autoRotationEnabled, setAutoRotationEnabled] = useState<boolean>(false);
|
||||
const [rotationInterval, setRotationInterval] = useState<string>("30d");
|
||||
const [routerSettings, setRouterSettings] = useState<RouterSettingsAccordionValue | null>(null);
|
||||
const [budgetLimits, setBudgetLimits] = useState<BudgetWindowEntry[]>([]);
|
||||
const [routerSettingsKey, setRouterSettingsKey] = useState<number>(0);
|
||||
const [agentsList, setAgentsList] = useState<{ agent_id: string; agent_name: string }[]>([]);
|
||||
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
|
||||
@@ -218,6 +221,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
||||
setSelectedAgentId(null);
|
||||
setSelectedOrganizationId(null);
|
||||
setSelectedProjectId(null);
|
||||
setBudgetLimits([]);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
@@ -236,6 +240,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
||||
setSelectedAgentId(null);
|
||||
setSelectedOrganizationId(null);
|
||||
setSelectedProjectId(null);
|
||||
setBudgetLimits([]);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -519,6 +524,12 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
||||
}
|
||||
}
|
||||
|
||||
// Add multi-window budget limits (filter out incomplete entries)
|
||||
const validWindows = budgetLimits.filter((w) => w.budget_duration && w.max_budget !== null && w.max_budget !== undefined);
|
||||
if (validWindows.length > 0) {
|
||||
formValues.budget_limits = validWindows;
|
||||
}
|
||||
|
||||
let response;
|
||||
if (keyOwner === "service_account") {
|
||||
response = await keyCreateServiceAccountCall(accessToken, formValues);
|
||||
@@ -540,6 +551,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
||||
setSoftBudget(response["soft_budget"]);
|
||||
NotificationsManager.success("Virtual Key Created");
|
||||
form.resetFields();
|
||||
setBudgetLimits([]);
|
||||
localStorage.removeItem("userData" + userID);
|
||||
} catch (error) {
|
||||
console.log("error in create key:", error);
|
||||
@@ -1045,6 +1057,22 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
||||
>
|
||||
<BudgetDurationDropdown onChange={(value) => form.setFieldValue("budget_duration", value)} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
className="mt-4"
|
||||
label={
|
||||
<span>
|
||||
Budget Windows{" "}
|
||||
<Tooltip title="Set multiple independent budget windows (e.g., hourly $10 AND monthly $200). Each window tracks spend separately and resets on its own schedule.">
|
||||
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<BudgetWindowsEditor
|
||||
value={budgetLimits}
|
||||
onChange={setBudgetLimits}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
className="mt-4"
|
||||
label={
|
||||
|
||||
@@ -10,11 +10,14 @@ import NotificationsManager from "./molecules/notifications_manager";
|
||||
import Navbar from "./navbar";
|
||||
import {
|
||||
agentHubPublicModelsCall,
|
||||
skillHubPublicCall,
|
||||
getPublicModelHubInfo,
|
||||
getUiConfig,
|
||||
mcpHubPublicServersCall,
|
||||
modelHubPublicModelsCall,
|
||||
} from "./networking";
|
||||
import { Plugin } from "./claude_code_plugins/types";
|
||||
import SkillHubDashboard from "./AIHub/SkillHubDashboard";
|
||||
import { generateCodeSnippet } from "./playground/chat_ui/CodeSnippets";
|
||||
import { getEndpointType } from "./playground/chat_ui/mode_endpoint_mapping";
|
||||
import { MessageType } from "./playground/chat_ui/types";
|
||||
@@ -120,6 +123,8 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
|
||||
const [selectedMcpServer, setSelectedMcpServer] = useState<null | MCPServerData>(null);
|
||||
const [proxySettings, setProxySettings] = useState<any>({});
|
||||
const [activeTab, setActiveTab] = useState<string>("models");
|
||||
const [skillHubData, setSkillHubData] = useState<Plugin[]>([]);
|
||||
const [skillLoading, setSkillLoading] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
const initializeAndFetch = async () => {
|
||||
@@ -180,11 +185,24 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
|
||||
setUsefulLinks(publicModelHubInfo.useful_links || {});
|
||||
};
|
||||
|
||||
const fetchSkillData = async () => {
|
||||
try {
|
||||
setSkillLoading(true);
|
||||
const response = await skillHubPublicCall();
|
||||
setSkillHubData(response.plugins ?? []);
|
||||
} catch (error) {
|
||||
console.error("There was an error fetching the public skill data", error);
|
||||
} finally {
|
||||
setSkillLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchPublicModelHubInfo();
|
||||
|
||||
fetchPublicData();
|
||||
fetchAgentData();
|
||||
fetchMcpData();
|
||||
fetchSkillData();
|
||||
};
|
||||
|
||||
initializeAndFetch();
|
||||
@@ -1294,6 +1312,15 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
|
||||
</div>
|
||||
</TabPane>
|
||||
)}
|
||||
|
||||
{/* Skill Hub Tab */}
|
||||
<TabPane tab="Skill Hub" key="skills">
|
||||
<SkillHubDashboard
|
||||
skills={skillHubData}
|
||||
isLoading={skillLoading}
|
||||
publicPage={true}
|
||||
/>
|
||||
</TabPane>
|
||||
</Tabs>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { Badge, Text } from "@tremor/react";
|
||||
import { Tooltip } from "antd";
|
||||
import { CopyOutlined, LinkOutlined } from "@ant-design/icons";
|
||||
import { Plugin } from "./claude_code_plugins/types";
|
||||
|
||||
export const skillHubColumns = (
|
||||
showModal: (skill: Plugin) => void,
|
||||
copyToClipboard: (text: string) => void,
|
||||
publicPage: boolean = false,
|
||||
): ColumnDef<Plugin>[] => [
|
||||
{
|
||||
header: "Skill Name",
|
||||
accessorKey: "name",
|
||||
enableSorting: true,
|
||||
sortingFn: "alphanumeric",
|
||||
cell: ({ row }) => {
|
||||
const skill = row.original;
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
type="button"
|
||||
className="font-medium text-sm cursor-pointer text-blue-600 hover:underline bg-transparent border-none p-0"
|
||||
onClick={() => showModal(skill)}
|
||||
>
|
||||
{skill.name}
|
||||
</button>
|
||||
<Tooltip title="Copy skill name">
|
||||
<CopyOutlined
|
||||
onClick={() => copyToClipboard(skill.name)}
|
||||
className="cursor-pointer text-gray-500 hover:text-blue-500 text-xs"
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{skill.description && (
|
||||
<Text className="text-xs text-gray-500 line-clamp-1 md:hidden">
|
||||
{skill.description}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Description",
|
||||
accessorKey: "description",
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<Text className="text-xs line-clamp-2">{row.original.description || "-"}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "Category",
|
||||
accessorKey: "category",
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => {
|
||||
const cat = row.original.category;
|
||||
if (!cat) return <Text className="text-xs text-gray-400">-</Text>;
|
||||
return <Badge color="blue" size="xs">{cat}</Badge>;
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Domain",
|
||||
accessorKey: "domain",
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => (
|
||||
<Text className="text-xs">{row.original.domain || "-"}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "Source",
|
||||
accessorKey: "source",
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const src = row.original.source;
|
||||
let url: string | null = null;
|
||||
let label = "-";
|
||||
if (src?.source === "github" && src.repo) {
|
||||
url = `https://github.com/${src.repo}`;
|
||||
label = src.repo;
|
||||
} else if (src?.source === "git-subdir" && src.url) {
|
||||
url = src.path ? `${src.url}/tree/main/${src.path}` : src.url;
|
||||
label = url.replace("https://github.com/", "");
|
||||
} else if (src?.source === "url" && src.url) {
|
||||
url = src.url;
|
||||
label = src.url.replace(/^https?:\/\//, "");
|
||||
}
|
||||
if (!url) return <Text className="text-xs text-gray-400">-</Text>;
|
||||
return (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-xs text-blue-600 hover:underline truncate max-w-[180px]"
|
||||
title={label}
|
||||
>
|
||||
<span className="truncate">{label}</span>
|
||||
<LinkOutlined className="shrink-0" style={{ fontSize: 10 }} />
|
||||
</a>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Status",
|
||||
accessorKey: "enabled",
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => (
|
||||
<Badge color={row.original.enabled ? "green" : "gray"} size="xs">
|
||||
{row.original.enabled ? "Public" : "Draft"}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -5,7 +5,7 @@ import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings"
|
||||
import PolicySelector from "@/components/policies/PolicySelector";
|
||||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
import { TextInput, Button as TremorButton } from "@tremor/react";
|
||||
import { Form, Input, Select, Switch, Tooltip } from "antd";
|
||||
import { Button as AntButton, Form, Input, InputNumber, Select, Switch, Tooltip } from "antd";
|
||||
import { useEffect, useState } from "react";
|
||||
import { rolesWithWriteAccess } from "../../utils/roles";
|
||||
import AgentSelector from "../agent_management/AgentSelector";
|
||||
@@ -16,6 +16,7 @@ import PassThroughRoutesSelector from "../common_components/PassThroughRoutesSel
|
||||
import RateLimitTypeFormItem from "../common_components/RateLimitTypeFormItem";
|
||||
import OrganizationDropdown from "../common_components/OrganizationDropdown";
|
||||
import { extractLoggingSettings, formatMetadataForDisplay, stripTagsFromMetadata } from "../key_info_utils";
|
||||
import { BudgetWindowEntry, BudgetWindowsEditor } from "../key_team_helpers/BudgetWindowsEditor";
|
||||
import { KeyResponse } from "../key_team_helpers/key_list";
|
||||
import MCPServerSelector from "../mcp_server_management/MCPServerSelector";
|
||||
import MCPToolPermissions from "../mcp_server_management/MCPToolPermissions";
|
||||
@@ -77,6 +78,7 @@ const getKeyTypeFromRoutes = (allowedRoutes: string[] | null | undefined): strin
|
||||
return "default";
|
||||
};
|
||||
|
||||
|
||||
export function KeyEditView({
|
||||
keyData,
|
||||
onCancel,
|
||||
@@ -103,6 +105,9 @@ export function KeyEditView({
|
||||
const [rotationInterval, setRotationInterval] = useState<string>(keyData.rotation_interval || "");
|
||||
const [neverExpire, setNeverExpire] = useState<boolean>(!keyData.expires);
|
||||
const [isKeySaving, setIsKeySaving] = useState(false);
|
||||
const [budgetLimits, setBudgetLimits] = useState<BudgetWindowEntry[]>(
|
||||
Array.isArray(keyData.budget_limits) ? keyData.budget_limits : []
|
||||
);
|
||||
const { data: organizations, isLoading: isOrganizationsLoading } = useOrganizations();
|
||||
const { data: projects } = useProjects();
|
||||
const { data: uiSettingsData } = useUISettings();
|
||||
@@ -274,6 +279,10 @@ export function KeyEditView({
|
||||
values.duration = null;
|
||||
}
|
||||
|
||||
// Include multi-window budget limits (filter out incomplete entries)
|
||||
const validWindows = budgetLimits.filter((w) => w.budget_duration && w.max_budget !== null && w.max_budget !== undefined);
|
||||
values.budget_limits = validWindows.length > 0 ? validWindows : undefined;
|
||||
|
||||
await onSubmit(values);
|
||||
} finally {
|
||||
setIsKeySaving(false);
|
||||
@@ -423,6 +432,22 @@ export function KeyEditView({
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
Budget Windows{" "}
|
||||
<Tooltip title="Set multiple independent budget windows (e.g., hourly $10 AND monthly $200). Each window tracks spend separately and resets on its own schedule.">
|
||||
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<BudgetWindowsEditor
|
||||
value={budgetLimits}
|
||||
onChange={setBudgetLimits}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="TPM Limit" name="tpm_limit">
|
||||
<NumericalInput min={0} />
|
||||
</Form.Item>
|
||||
|
||||
Reference in New Issue
Block a user