Skip to content

feat: multiple concurrent budget windows per API key and team (#24883) - #25109

Merged
ishaan-berri merged 5 commits into
litellm_ishaan_april6from
litellm_ishaan_april3
Apr 6, 2026
Merged

feat: multiple concurrent budget windows per API key and team (#24883)#25109
ishaan-berri merged 5 commits into
litellm_ishaan_april6from
litellm_ishaan_april3

Conversation

@ishaan-berri

Copy link
Copy Markdown
Contributor
  • 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


Relevant issues

Pre-Submission checklist

Please complete all items before asking a LiteLLM maintainer to review your PR

  • I have Added testing in the tests/test_litellm/ directory, Adding at least 1 test is a hard requirement - see details
  • My PR passes all unit tests on make test-unit
  • My PR's scope is as isolated as possible, it only solves 1 specific problem
  • I have requested a Greptile review by commenting @greptileai and received a Confidence Score of at least 4/5 before requesting a maintainer review

Delays in PR merge?

If you're seeing a delay in your PR being merged, ping the LiteLLM Team on Slack (#pr-review).

CI (LiteLLM team)

CI status guideline:

  • 50-55 passing tests: main is stable with minor issues.
  • 45-49 passing tests: acceptable but needs attention
  • <= 40 passing tests: unstable; be careful with your merges and assess the risk.
  • Branch creation CI run
    Link:

  • CI run for the last commit
    Link:

  • Merge / cherry-pick CI run
    Links:

Type

🆕 New Feature
🐛 Bug Fix
🧹 Refactoring
📖 Documentation
🚄 Infrastructure
✅ Test

Changes

* 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>
@CLAassistant

CLAassistant commented Apr 3, 2026

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you all sign our Contributor License Agreement before we can accept your contribution.
0 out of 2 committers have signed the CLA.

❌ ishaan-berri
❌ yuneng-berri
You have signed the CLA already but the status is still pending? Let us recheck it.

@vercel

vercel Bot commented Apr 3, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
litellm Ready Ready Preview, Comment Apr 6, 2026 9:04pm

Request Review

@codspeed-hq

codspeed-hq Bot commented Apr 3, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 16 untouched benchmarks


Comparing litellm_ishaan_april3 (f6911b2) with main (48d4dec)

Open in CodSpeed

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";

Check notice

Code scanning / CodeQL

Unused variable, import, function or class Note

Unused import InputNumber.

Copilot Autofix

AI 5 months ago

In general, unused imports should be removed from the import list to keep the codebase clean, avoid confusion, and possibly reduce bundle size. Since InputNumber is unused, the best fix is to delete InputNumber from the antd named import without altering any other imports or functionality.

Concretely, in ui/litellm-dashboard/src/components/organisms/create_key_button.tsx, locate the line:

import { Button as Button2, Form, Input, InputNumber, Modal, Radio, Select, Switch, Tag, Tooltip } from "antd";

and remove InputNumber from the destructuring list so that the remaining imports stay intact. No additional methods, imports, or definitions are required.

Suggested changeset 1
ui/litellm-dashboard/src/components/organisms/create_key_button.tsx

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx b/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx
--- a/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx
+++ b/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx
@@ -9,7 +9,7 @@
 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, InputNumber, Modal, Radio, Select, Switch, Tag, Tooltip } from "antd";
+import { Button as Button2, Form, Input, 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";
EOF
@@ -9,7 +9,7 @@
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, InputNumber, Modal, Radio, Select, Switch, Tag, Tooltip } from "antd";
import { Button as Button2, Form, Input, 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";
Copilot is powered by AI and may make mistakes. Always verify output.
Unable to commit as this autofix suggestion is now outdated
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";

Check notice

Code scanning / CodeQL

Unused variable, import, function or class Note

Unused imports AntButton, InputNumber.

Copilot Autofix

AI 5 months ago

In general, the correct fix for unused imports is to remove the specific symbols from the import statement, keeping only the ones that are actually used. This improves readability and avoids misleading future maintainers.

In this file, the best fix is to edit the antd import on line 8 to drop Button as AntButton and InputNumber while preserving Form, Input, Select, Switch, and Tooltip as-is. No other lines or imports need to be changed, and no new code or behavior is required. The edit is localized to the single import line in ui/litellm-dashboard/src/components/templates/key_edit_view.tsx.

Suggested changeset 1
ui/litellm-dashboard/src/components/templates/key_edit_view.tsx

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx b/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx
--- a/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx
+++ b/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx
@@ -5,7 +5,7 @@
 import PolicySelector from "@/components/policies/PolicySelector";
 import { InfoCircleOutlined } from "@ant-design/icons";
 import { TextInput, Button as TremorButton } from "@tremor/react";
-import { Button as AntButton, Form, Input, InputNumber, Select, Switch, Tooltip } from "antd";
+import { Form, Input, Select, Switch, Tooltip } from "antd";
 import { useEffect, useState } from "react";
 import { rolesWithWriteAccess } from "../../utils/roles";
 import AgentSelector from "../agent_management/AgentSelector";
EOF
@@ -5,7 +5,7 @@
import PolicySelector from "@/components/policies/PolicySelector";
import { InfoCircleOutlined } from "@ant-design/icons";
import { TextInput, Button as TremorButton } from "@tremor/react";
import { Button as AntButton, Form, Input, InputNumber, Select, Switch, Tooltip } from "antd";
import { Form, Input, Select, Switch, Tooltip } from "antd";
import { useEffect, useState } from "react";
import { rolesWithWriteAccess } from "../../utils/roles";
import AgentSelector from "../agent_management/AgentSelector";
Copilot is powered by AI and may make mistakes. Always verify output.
Unable to commit as this autofix suggestion is now outdated
@greptile-apps

greptile-apps Bot commented Apr 3, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR introduces multiple concurrent budget windows per API key and team — for example a $5/hour cap and a $100/month cap on the same key. The feature spans the full stack: a new budget_limits JSONB column (migration included), BudgetLimitEntry Pydantic type, per-window Redis counters incremented after each request, auth-time enforcement via new _virtual_key_multi_budget_check / _team_multi_budget_check checks, a scheduler job to reset expired windows, and a BudgetWindowsEditor UI component. It also fixes BudgetExceededError to return HTTP 429 instead of 500.

Key changes:

  • litellm/proxy/auth/auth_checks.py_virtual_key_multi_budget_check and _team_multi_budget_check enforce per-window limits at auth time
  • litellm/proxy/proxy_server.pyincrement_spend_counters atomically increments per-window Redis counters after each request
  • litellm/proxy/common_utils/reset_budget_job.pyreset_budget_windows resets expired per-window counters on schedule; contains a timezone comparison bug (see inline comment)
  • litellm/proxy/_types.pyBudgetLimitEntry type; budget_limits field added to GenerateRequestBase, TeamBase, and LiteLLM_VerificationToken
  • litellm/exceptions.py + auth_exception_handler.pyBudgetExceededError now returns 429
  • Migration adds budget_limits JSONB to both tables with IF NOT EXISTS (idempotent)
  • Five focused unit tests cover the key auth-check path

Issue to address before merge:

  • The _reset_expired_window helper compares a timezone-stripped local-time value against datetime.utcnow(), which produces incorrect reset timing when litellm_settings.timezone is configured to a non-UTC zone (windows reset too early for negative offsets, too late for positive offsets)."

Confidence Score: 4/5

PR is mostly safe to merge; one P1 timezone comparison bug in reset_budget_windows affects deployments using non-UTC litellm_settings.timezone.

All prior review concerns (N+1 query, inline imports, type inconsistency, React key, duplicate-duration) are noted but not newly introduced blocking issues. The one new P1 finding is the timezone stripping in _reset_expired_window that causes windows to reset at the wrong UTC time for non-UTC timezone configurations. This only affects users who have explicitly set litellm_settings.timezone to a non-UTC zone; UTC deployments (the majority) are unaffected. Score of 4 reflects this single actionable fix needed before merge.

litellm/proxy/common_utils/reset_budget_job.py (timezone comparison bug in _reset_expired_window)

Important Files Changed

Filename Overview
litellm/proxy/auth/auth_checks.py Adds _virtual_key_multi_budget_check and _team_multi_budget_check that iterate budget_limits windows, query per-window Redis counters via get_current_spend, and raise BudgetExceededError (429) if any window is exceeded; both wired into common_checks.
litellm/proxy/common_utils/reset_budget_job.py Adds reset_budget_windows() and _reset_expired_window() to reset expired per-window counters on schedule; contains a timezone comparison bug where ISO strings with non-UTC offsets are stripped before comparison with datetime.utcnow().
litellm/proxy/proxy_server.py Adds increment_spend_counters() atomically incrementing per-entity and per-window Redis spend counters after each request; introduces get_current_spend() Redis-first helper.
litellm/proxy/_types.py Introduces BudgetLimitEntry Pydantic model and adds budget_limits to GenerateRequestBase, TeamBase (typed List[BudgetLimitEntry]) and LiteLLM_VerificationToken (typed List[dict]).
litellm/exceptions.py Fixes BudgetExceededError to set self.status_code = 429, enabling the auth exception handler to return HTTP 429 for budget-exceeded errors.
litellm/proxy/auth/auth_exception_handler.py Adds explicit isinstance(e, litellm.BudgetExceededError) branch raising ProxyException with code=429.
litellm/proxy/management_endpoints/key_management_endpoints.py Stamps reset_at timestamps onto each budget_limits window and JSON-serialises them on key create/update before DB write.
litellm/proxy/management_endpoints/team_endpoints.py Stamps reset_at onto each budget_limits window on team create/update via extracted _set_budget_reset_at() helper.
tests/test_litellm/proxy/auth/test_multi_budget_windows.py Five async pytest unit tests for _virtual_key_multi_budget_check: covers empty limits, under-budget, over-daily-window (429 + message check), over-monthly-window, and BudgetLimitEntry coercion.
ui/litellm-dashboard/src/components/key_team_helpers/BudgetWindowsEditor.tsx New shared React component for editing budget windows; four fixed duration options (1h/24h/7d/30d), InputNumber for dollar limit, remove button and reset hint per row.
litellm-proxy-extras/litellm_proxy_extras/migrations/20260401000000_add_budget_limits/migration.sql Idempotent ALTER TABLE … ADD COLUMN IF NOT EXISTS … JSONB migration adding budget_limits to LiteLLM_VerificationToken and LiteLLM_TeamTable.

Sequence Diagram

sequenceDiagram
    participant Client
    participant AuthChecks
    participant Redis as Redis (spend_counter_cache)
    participant DB as PostgreSQL
    participant ResetJob as reset_budget_job

    Client->>AuthChecks: request with API key
    AuthChecks->>Redis: get_current_spend(spend:key:{token}:window:{duration})
    Redis-->>AuthChecks: window_spend
    alt window_spend >= max_budget
        AuthChecks-->>Client: 429 BudgetExceededError
    else all windows OK
        AuthChecks-->>Client: proceed
    end

    Client->>AuthChecks: (after response) increment_spend_counters
    AuthChecks->>Redis: async_increment_cache(spend:key:{token}:window:{duration}, cost)
    Redis-->>AuthChecks: ok

    ResetJob->>DB: find_many(budget_limits != null)
    DB-->>ResetJob: keys/teams with budget_limits
    loop for each expired window
        ResetJob->>Redis: async_set_cache(counter_key, 0.0)
        ResetJob->>DB: update(budget_limits with new reset_at)
    end
Loading

Reviews (4): Last reviewed commit: "Merge branch 'litellm_ishaan_april6' int..." | Re-trigger Greptile

Comment on lines +599 to +618
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]
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Unbounded find_many + N+1 update() writes inside a loop

Both the key and team loops fetch the entire table with no take limit, and then issue one update() call per changed record inside the loop. Per the project DB rules, large result sets must be paginated and writes must be batched rather than N+1'd.

On an installation with thousands of keys that have budget_limits, this will:

  1. Materialise the full key table into memory (OOM risk)
  2. Fire one DB round-trip per changed window (deadlock / latency risk during the reset job)

Consider pagination via cursor-based take/cursor and batching the window update payloads, or writing a single UPDATE … SET … WHERE … via update_many with a JSON column expression, and apply the same fix to the teams block (lines 626–649).

if not valid_token.budget_limits:
return

from litellm.proxy.proxy_server import get_current_spend

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Inline import inside function body violates project style guide

The project's CLAUDE.md explicitly states: "Avoid imports within methods — place all imports at the top of the file (module-level). The only exception is avoiding circular imports where absolutely necessary."

This same pattern (from litellm.proxy.proxy_server import get_current_spend placed inside a function body) also appears in _team_multi_budget_check at line ~3237. Both should be moved to module-level, or — if a circular import genuinely exists — a # noqa: PLC0415 comment with a brief explanation should be added.

The same pattern also appears in reset_budget_job.py (from litellm.proxy.proxy_server import spend_counter_cache inside reset_budget_windows/_reset_budget_common), and from litellm.proxy.common_utils.timezone_utils import get_budget_reset_time inside function bodies in key_management_endpoints.py and team_endpoints.py.

Context Used: CLAUDE.md (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment thread litellm/proxy/_types.py
@@ -2361,6 +2380,7 @@ class LiteLLM_VerificationToken(LiteLLMPydanticObjectBase):
last_rotation_at: Optional[datetime] = None # When this key was last rotated

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Type inconsistency: LiteLLM_VerificationToken.budget_limits typed as Optional[List[dict]]

GenerateRequestBase and TeamBase declare budget_limits as Optional[List[BudgetLimitEntry]], giving Pydantic the schema to validate each entry. However, LiteLLM_VerificationToken (the read-path type populated from the DB cache) uses the weaker Optional[List[dict]], so no field-level validation is performed when a cached token is loaded and the budget_limits list is iterated in _virtual_key_multi_budget_check. Aligning both declarations to Optional[List[BudgetLimitEntry]] would make the type system the single source of truth and avoid the isinstance(window, dict) … window.model_dump() defensive branches scattered through the auth and spend paths.

<div key={idx} style={{ marginBottom: 12 }}>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<Select
value={window.budget_duration}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Array index used as React key

Using array position as the key prop means React reuses existing DOM nodes with shifted indices when a row is deleted from the middle of the list. This can produce stale field values or flickering after a remove. Consider assigning a stable id string (e.g. from a ref counter) to each window entry on creation and using that as the key instead.

…ub, 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
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

Check notice

Code scanning / CodeQL

Cyclic import Note

Import of module
litellm.proxy.proxy_server
begins an import cycle.
Import of module
proxy_server
begins an import cycle.

Copilot Autofix

AI 5 months ago

General approach: Break the cycle by stopping reset_budget_job.py from importing proxy_server. Instead, obtain spend_counter_cache via dependency injection (passing it in) or via a neutral utility module that both sides can import without a cycle. Since we cannot modify other files, the safest change is to refactor reset_budget_windows to receive spend_counter_cache as an argument and remove the import from proxy_server.

Concrete best fix here:

  • Change reset_budget_windows to accept spend_counter_cache as a parameter instead of importing it:
async def reset_budget_windows(self, spend_counter_cache) -> None:
    ...
  • Remove the line from litellm.proxy.proxy_server import spend_counter_cache.
  • Use the parameter spend_counter_cache everywhere the function currently uses the imported spend_counter_cache.

This keeps all existing behavior the same, assuming callers (e.g., proxy_server) already have access to spend_counter_cache and can pass it in. It also removes the direct dependency from reset_budget_job.py to proxy_server, breaking the cycle CodeQL reported.

Specific changes in litellm/proxy/common_utils/reset_budget_job.py:

  • Around line 588: update the method signature to async def reset_budget_windows(self, spend_counter_cache) -> None:.
  • Remove line 593 (the from litellm.proxy.proxy_server import spend_counter_cache import).
  • No new imports or helpers are needed; spend_counter_cache is already used in the body and will be provided by the caller.
Suggested changeset 1
litellm/proxy/common_utils/reset_budget_job.py

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/litellm/proxy/common_utils/reset_budget_job.py b/litellm/proxy/common_utils/reset_budget_job.py
--- a/litellm/proxy/common_utils/reset_budget_job.py
+++ b/litellm/proxy/common_utils/reset_budget_job.py
@@ -585,12 +585,11 @@
         ).isoformat()
         return True
 
-    async def reset_budget_windows(self) -> None:
+    async def reset_budget_windows(self, spend_counter_cache) -> 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()
 
EOF
@@ -585,12 +585,11 @@
).isoformat()
return True

async def reset_budget_windows(self) -> None:
async def reset_budget_windows(self, spend_counter_cache) -> 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()

Copilot is powered by AI and may make mistakes. Always verify output.
Unable to commit as this autofix suggestion is now outdated
Comment on lines +1794 to +1804
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,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Duplicate budget_duration silently double-counts per-window Redis spend

When two entries in budget_limits share the same budget_duration (e.g., two "24h" rows), async_increment_cache is called twice for the identical Redis counter key on every request. After a $3 request the counter holds $6, and the auth check in _virtual_key_multi_budget_check compares the inflated value against each entry's max_budget — effectively enforcing limits at half the actual spend.

The same flaw applies to the team block at lines 1822–1831.

There is no server-side validation preventing duplicates, and the UI's BudgetWindowsEditor defaults every new row to "24h" (so clicking "+ Add Budget Window" twice creates duplicates without any warning).

A Pydantic @model_validator on GenerateRequestBase (and TeamBase) that checks uniqueness of budget_duration within the list would catch this at the API boundary:

@model_validator(mode="after")
def check_unique_budget_durations(self) -> "GenerateRequestBase":
    if self.budget_limits:
        seen: set = set()
        for entry in self.budget_limits:
            duration = (
                entry.budget_duration
                if hasattr(entry, "budget_duration")
                else entry.get("budget_duration")
            )
            if duration in seen:
                raise ValueError(
                    f"budget_limits contains duplicate budget_duration '{duration}'. "
                    "Each duration must appear at most once."
                )
            seen.add(duration)
    return self

The same guard should also be added in prepare_key_update_data since budget_limits can arrive as raw dicts on the update path.

Comment on lines +22 to +24
const addWindow = () => {
onChange([...value, { budget_duration: "24h", max_budget: null }]);
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Default duration "24h" on every new row enables accidental duplicate-duration creation

addWindow always appends { budget_duration: "24h", max_budget: null }. A user who clicks "+ Add Budget Window" twice without changing the first row's duration ends up with two identical budget_duration values. As described in the server-side comment, this causes the Redis counter for that window to be incremented twice per request, silently halving the effective budget limit.

Consider disabling the Select options that are already in use, or at minimum picking the first unused duration as the default:

const usedDurations = new Set(value.map((w) => w.budget_duration));
const availableOptions = BUDGET_WINDOW_OPTIONS.filter(
  (o) => !usedDurations.has(o.value)
);
const addWindow = () => {
  const nextDuration = availableOptions[0]?.value ?? "24h";
  onChange([...value, { budget_duration: nextDuration, max_budget: null }]);
};

And inside the Select for each row, mark already-used options as disabled to prevent switching to a duplicate duration.

@yuneng-berri
yuneng-berri requested a review from a team April 5, 2026 08:01
@ishaan-berri
ishaan-berri changed the base branch from main to litellm_ishaan_april6 April 6, 2026 21:01
@ishaan-berri
ishaan-berri had a problem deploying to integration-postgres April 6, 2026 21:01 — with GitHub Actions Failure
@ishaan-berri
ishaan-berri had a problem deploying to integration-postgres April 6, 2026 21:01 — with GitHub Actions Failure
@ishaan-berri
ishaan-berri had a problem deploying to integration-redis-postgres April 6, 2026 21:01 — with GitHub Actions Failure
@ishaan-berri
ishaan-berri had a problem deploying to integration-postgres April 6, 2026 21:01 — with GitHub Actions Failure
@ishaan-berri
ishaan-berri had a problem deploying to integration-postgres April 6, 2026 21:01 — with GitHub Actions Failure
@ishaan-berri
ishaan-berri merged commit 0afffe4 into litellm_ishaan_april6 Apr 6, 2026
4 of 46 checks passed
@ishaan-berri
ishaan-berri deleted the litellm_ishaan_april3 branch April 6, 2026 21:02
Comment on lines +556 to +586
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Timezone stripping causes incorrect reset timing for non-UTC configurations

When litellm_settings.timezone is set to a non-UTC zone, get_budget_reset_time returns a timezone-aware datetime whose ISO string encodes a UTC offset (e.g. "2026-04-07T00:00:00+05:30" for IST). Inside _reset_expired_window, the code strips that offset unconditionally:

reset_at = datetime.fromisoformat(
    reset_at_str.replace("Z", "+00:00")
).replace(tzinfo=None)   # drops offset, keeps LOCAL clock value
if reset_at > now:       # now = datetime.utcnow() (always UTC)
    return False

For a negative-offset zone (e.g. US/Eastern, UTC-5):

  • Stored: "2026-04-07T00:00:00-05:00" → stripped naive: 2026-04-07T00:00:00
  • datetime.utcnow() at 4:30 UTC = 2026-04-07T04:30:00
  • Comparison: 00:00 > 04:30 → False → window resets 30 min early (correct reset is at 05:00 UTC)

For a positive-offset zone (e.g. Asia/Tokyo, UTC+9):

  • Stored: "2026-04-07T00:00:00+09:00" → stripped naive: 2026-04-07T00:00:00
  • datetime.utcnow() at 15:30 UTC = 2026-04-06T15:30:00
  • Comparison: 2026-04-07T00:00:00 > 2026-04-06T15:30:00 → True → window is NOT reset even though its UTC equivalent (2026-04-06T15:00:00Z) is already in the past

The fix is to convert reset_at to UTC before the naive comparison:

from datetime import timezone as _timezone

reset_at = datetime.fromisoformat(
    reset_at_str.replace("Z", "+00:00")
)
# Normalise to naive-UTC before comparing with datetime.utcnow()
if reset_at.tzinfo is not None:
    reset_at = reset_at.astimezone(_timezone.utc).replace(tzinfo=None)
if reset_at > now:
    return False

Alternatively, change now to datetime.now(timezone.utc) and skip the .replace(tzinfo=None) call entirely, keeping the comparison timezone-aware throughout.

@ishaan-berri ishaan-berri mentioned this pull request Apr 7, 2026
5 tasks
fzowl pushed a commit to fzowl/litellm that referenced this pull request Jun 24, 2026
…I#24883) (BerriAI#25109)

* feat: multiple concurrent budget windows per API key and team (BerriAI#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 (BerriAI#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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants