mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-12 23:01:41 +00:00
Merge pull request #33581 from BerriAI/litellm_lit4478_anthropic_auto_cache_ui
feat(ui): configure Anthropic automatic prompt caching from the Admin UI
This commit is contained in:
commit
3ba5266ab3
7 changed files with 467 additions and 42 deletions
|
|
@ -1517,6 +1517,12 @@ LITELLM_SETTINGS_SAFE_DB_OVERRIDES = [
|
|||
"cost_discount_config",
|
||||
"cost_margin_config",
|
||||
"budget_exceeded_throttle_percentage",
|
||||
# Every field editable from the Admin UI (proxy_server._GENERAL_SETTINGS_UI_LITELLM_FIELDS)
|
||||
# must be listed here so a DB write from one worker overrides the live litellm attribute on
|
||||
# the others when config reloads; otherwise peer workers stay on their startup value.
|
||||
# test_general_settings_ui_fields_are_db_overridable enforces that pairing.
|
||||
"enable_anthropic_prompt_caching",
|
||||
"anthropic_prompt_caching_ttl",
|
||||
]
|
||||
SPECIAL_LITELLM_AUTH_TOKEN = ["ui-token"]
|
||||
DEFAULT_MANAGEMENT_OBJECT_IN_MEMORY_CACHE_TTL = int(os.getenv("DEFAULT_MANAGEMENT_OBJECT_IN_MEMORY_CACHE_TTL", 60))
|
||||
|
|
|
|||
|
|
@ -1011,10 +1011,10 @@ class LiteLLM_ObjectPermissionBase(LiteLLMPydanticObjectBase):
|
|||
mcp_tool_search_enabled: Optional[bool] = None
|
||||
|
||||
|
||||
from litellm.models.team import BudgetLimitEntry as BudgetLimitEntry # noqa: E402
|
||||
from litellm.types.object_permission import ( # noqa: E402
|
||||
ObjectPermissionDict as ObjectPermissionDict,
|
||||
)
|
||||
from litellm.models.team import BudgetLimitEntry as BudgetLimitEntry # noqa: E402
|
||||
|
||||
|
||||
class GenerateRequestBase(LiteLLMPydanticObjectBase):
|
||||
|
|
@ -2122,6 +2122,8 @@ class ConfigList(LiteLLMPydanticObjectBase):
|
|||
field_default_value: Any
|
||||
premium_field: bool = False
|
||||
nested_fields: Optional[List[FieldDetail]] = None # For nested dictionary or Pydantic fields
|
||||
field_options: Optional[list[str]] = None # Allowed values, for field_type == "Select"
|
||||
field_tab: Optional[str] = None # Admin UI sub-tab this field renders under; None groups it with the rest
|
||||
|
||||
|
||||
class UserHeaderMapping(LiteLLMPydanticObjectBase):
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ from typing import (
|
|||
Optional,
|
||||
Set,
|
||||
Tuple,
|
||||
TypedDict,
|
||||
Union,
|
||||
cast,
|
||||
get_args,
|
||||
|
|
@ -39,6 +40,7 @@ import anyio
|
|||
import websockets
|
||||
import websockets.exceptions
|
||||
from pydantic import BaseModel, Json, JsonValue
|
||||
from typing_extensions import NotRequired, assert_never
|
||||
|
||||
from litellm._uuid import uuid
|
||||
from litellm.constants import (
|
||||
|
|
@ -363,15 +365,15 @@ from litellm.proxy.management_endpoints.cache_settings_endpoints import (
|
|||
from litellm.proxy.management_endpoints.callback_management_endpoints import (
|
||||
router as callback_management_endpoints_router,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.coordination_redis_endpoints import (
|
||||
get_persisted_coordination_redis_settings,
|
||||
router as coordination_redis_settings_router,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.common_utils import (
|
||||
_user_has_admin_privileges,
|
||||
_user_has_admin_view,
|
||||
admin_can_invite_user,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.coordination_redis_endpoints import (
|
||||
get_persisted_coordination_redis_settings,
|
||||
router as coordination_redis_settings_router,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.cost_tracking_settings import (
|
||||
router as cost_tracking_settings_router,
|
||||
)
|
||||
|
|
@ -14828,7 +14830,17 @@ async def get_config_general_settings(
|
|||
)
|
||||
|
||||
|
||||
_GENERAL_SETTINGS_UI_LITELLM_FIELDS: dict[str, dict[str, str]] = {
|
||||
GeneralSettingsUILiteLLMValue = Union[float, bool, str, None]
|
||||
|
||||
|
||||
class GeneralSettingsUILiteLLMFieldSpec(TypedDict):
|
||||
type: Literal["Float", "Boolean", "Select"]
|
||||
description: str
|
||||
options: NotRequired[tuple[str, ...]]
|
||||
tab: NotRequired[str] # Admin UI sub-tab this field renders under; None groups it with the rest
|
||||
|
||||
|
||||
_GENERAL_SETTINGS_UI_LITELLM_FIELDS: dict[str, GeneralSettingsUILiteLLMFieldSpec] = {
|
||||
"budget_exceeded_throttle_percentage": {
|
||||
"type": "Float",
|
||||
"description": (
|
||||
|
|
@ -14837,18 +14849,60 @@ _GENERAL_SETTINGS_UI_LITELLM_FIELDS: dict[str, dict[str, str]] = {
|
|||
"over-budget keys."
|
||||
),
|
||||
},
|
||||
"enable_anthropic_prompt_caching": {
|
||||
"type": "Boolean",
|
||||
"tab": "prompt_caching",
|
||||
"description": (
|
||||
"Auto-adds cache_control to the system prompt and trailing turn for supported Anthropic "
|
||||
"and Bedrock Claude models. The cache is shared across callers on the same upstream credentials."
|
||||
),
|
||||
},
|
||||
"anthropic_prompt_caching_ttl": {
|
||||
"type": "Select",
|
||||
"options": ("5m", "1h"),
|
||||
"tab": "prompt_caching",
|
||||
"description": "Empty uses Anthropic's 5m default. 1h suits long sessions but doubles the cache write cost.",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _validate_general_settings_ui_litellm_value(field_name: str, value: Any) -> Optional[float]:
|
||||
def _general_settings_ui_litellm_default(
|
||||
field_type: Literal["Float", "Boolean", "Select"],
|
||||
) -> GeneralSettingsUILiteLLMValue:
|
||||
"""The value a field falls back to when it is cleared or reset."""
|
||||
return False if field_type == "Boolean" else None
|
||||
|
||||
|
||||
def _validate_general_settings_ui_litellm_value(field_name: str, value: Any) -> GeneralSettingsUILiteLLMValue:
|
||||
spec = _GENERAL_SETTINGS_UI_LITELLM_FIELDS[field_name]
|
||||
field_type = spec["type"]
|
||||
if value is None or value == "":
|
||||
return None
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)) or not (0 < float(value) <= 1):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={"error": f"{field_name} must be a number in (0, 1] or empty"},
|
||||
)
|
||||
return float(value)
|
||||
return _general_settings_ui_litellm_default(field_type)
|
||||
match field_type:
|
||||
case "Boolean":
|
||||
if not isinstance(value, bool):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={"error": f"{field_name} must be true or false"},
|
||||
)
|
||||
return value
|
||||
case "Select":
|
||||
options = spec.get("options", ())
|
||||
if value not in options:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={"error": f"{field_name} must be one of: {', '.join(options)}, or empty"},
|
||||
)
|
||||
return cast(str, value) # cast-ok: membership in options proves it is one of the option strings
|
||||
case "Float":
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)) or not (0 < float(value) <= 1):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={"error": f"{field_name} must be a number in (0, 1] or empty"},
|
||||
)
|
||||
return float(value)
|
||||
case _:
|
||||
assert_never(field_type)
|
||||
|
||||
|
||||
async def _persist_general_settings_ui_litellm_field(
|
||||
|
|
@ -14869,11 +14923,12 @@ async def _persist_general_settings_ui_litellm_field(
|
|||
async def _reset_general_settings_ui_litellm_field(field_name: str, user_api_key_dict: UserAPIKeyAuth) -> dict:
|
||||
config = await proxy_config.get_config()
|
||||
before_value = config.get("litellm_settings", {}).get(field_name)
|
||||
setattr(litellm, field_name, None)
|
||||
default_value = _general_settings_ui_litellm_default(_GENERAL_SETTINGS_UI_LITELLM_FIELDS[field_name]["type"])
|
||||
setattr(litellm, field_name, default_value)
|
||||
if "litellm_settings" in config:
|
||||
config["litellm_settings"].pop(field_name, None)
|
||||
await proxy_config.save_config(new_config=config)
|
||||
asyncio.create_task(create_config_audit_log(field_name, "deleted", before_value, None, user_api_key_dict))
|
||||
asyncio.create_task(create_config_audit_log(field_name, "deleted", before_value, default_value, user_api_key_dict))
|
||||
return {"message": f"Field {field_name} reset", "status": "success"}
|
||||
|
||||
|
||||
|
|
@ -15041,11 +15096,12 @@ async def get_config_list(
|
|||
else {}
|
||||
)
|
||||
for litellm_field_name, spec in _GENERAL_SETTINGS_UI_LITELLM_FIELDS.items():
|
||||
current_value: Optional[float] = getattr(litellm, litellm_field_name, None)
|
||||
current_value: GeneralSettingsUILiteLLMValue = getattr(litellm, litellm_field_name, None)
|
||||
default_value = _general_settings_ui_litellm_default(spec["type"])
|
||||
stored_in_db_litellm: Optional[bool]
|
||||
if litellm_field_name in db_litellm_settings:
|
||||
stored_in_db_litellm = True
|
||||
elif current_value is not None:
|
||||
elif current_value != default_value:
|
||||
stored_in_db_litellm = False
|
||||
else:
|
||||
stored_in_db_litellm = None
|
||||
|
|
@ -15056,7 +15112,9 @@ async def get_config_list(
|
|||
field_description=spec["description"],
|
||||
field_value=current_value,
|
||||
stored_in_db=stored_in_db_litellm,
|
||||
field_default_value=None,
|
||||
field_default_value=default_value,
|
||||
field_options=list(spec.get("options", ())) or None,
|
||||
field_tab=spec.get("tab"),
|
||||
nested_fields=None,
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -8984,6 +8984,261 @@ async def test_update_config_field_throttle_persists_to_litellm_settings(monkeyp
|
|||
assert saved["litellm_settings"]["budget_exceeded_throttle_percentage"] == 0.1
|
||||
|
||||
|
||||
def test_get_config_list_includes_anthropic_prompt_caching_fields(monkeypatch):
|
||||
"""The auto prompt caching flag and its ttl are litellm_settings globals surfaced on the
|
||||
General Settings table, so an admin can turn caching on without hand-writing config. The
|
||||
ttl is a Select and must ship its allowed values, or the table renders no editor for it."""
|
||||
import types
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import litellm.proxy.proxy_server as ps
|
||||
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
|
||||
from litellm.proxy.proxy_server import app
|
||||
|
||||
mock_prisma = MagicMock()
|
||||
mock_config_table = MagicMock()
|
||||
mock_config_table.find_first = AsyncMock(return_value=None)
|
||||
mock_prisma.db = types.SimpleNamespace(litellm_config=mock_config_table)
|
||||
monkeypatch.setattr(ps, "prisma_client", mock_prisma)
|
||||
monkeypatch.setattr(litellm, "enable_anthropic_prompt_caching", True)
|
||||
monkeypatch.setattr(litellm, "anthropic_prompt_caching_ttl", "1h")
|
||||
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
|
||||
user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN
|
||||
)
|
||||
try:
|
||||
client = TestClient(app)
|
||||
resp = client.get("/config/list", params={"config_type": "general_settings"})
|
||||
assert resp.status_code == 200, resp.text
|
||||
fields = {item["field_name"]: item for item in resp.json()}
|
||||
|
||||
assert fields["enable_anthropic_prompt_caching"]["field_type"] == "Boolean"
|
||||
assert fields["enable_anthropic_prompt_caching"]["field_value"] is True
|
||||
|
||||
assert fields["anthropic_prompt_caching_ttl"]["field_type"] == "Select"
|
||||
assert fields["anthropic_prompt_caching_ttl"]["field_value"] == "1h"
|
||||
assert fields["anthropic_prompt_caching_ttl"]["field_options"] == ["5m", "1h"]
|
||||
|
||||
# Both caching fields carry their sub-tab so the Admin UI can render them on a
|
||||
# dedicated Prompt Caching tab, while ungrouped fields stay on General.
|
||||
assert fields["enable_anthropic_prompt_caching"]["field_tab"] == "prompt_caching"
|
||||
assert fields["anthropic_prompt_caching_ttl"]["field_tab"] == "prompt_caching"
|
||||
assert fields["budget_exceeded_throttle_percentage"]["field_tab"] is None
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_general_settings_ui_fields_are_db_overridable():
|
||||
"""Every field the Admin UI can edit is a `litellm.<attr>` set via setattr on the handling
|
||||
worker (`_persist_general_settings_ui_litellm_field`). Unless it is also in
|
||||
LITELLM_SETTINGS_SAFE_DB_OVERRIDES, a config reload on a peer worker merges the DB value but
|
||||
never applies it to the live attribute, so peer workers stay on their startup value.
|
||||
|
||||
This invariant is the guard against the two registries drifting: adding a UI-editable field
|
||||
without enrolling it in the DB-override allowlist silently breaks cross-worker propagation.
|
||||
"""
|
||||
from litellm.constants import LITELLM_SETTINGS_SAFE_DB_OVERRIDES
|
||||
from litellm.proxy.proxy_server import _GENERAL_SETTINGS_UI_LITELLM_FIELDS
|
||||
|
||||
missing = set(_GENERAL_SETTINGS_UI_LITELLM_FIELDS) - set(LITELLM_SETTINGS_SAFE_DB_OVERRIDES)
|
||||
assert not missing, (
|
||||
f"UI-editable litellm_settings fields missing from LITELLM_SETTINGS_SAFE_DB_OVERRIDES: {sorted(missing)}. "
|
||||
"Add them, or they will not propagate to other workers when changed from the UI."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field_name, db_value",
|
||||
[
|
||||
("enable_anthropic_prompt_caching", True),
|
||||
("anthropic_prompt_caching_ttl", "1h"),
|
||||
],
|
||||
)
|
||||
def test_prompt_caching_settings_propagate_on_config_reload(monkeypatch, field_name, db_value):
|
||||
"""A UI toggle on one worker persists to the DB; a peer worker picks it up only when the
|
||||
config reload applies the safe-override allowlist. Regression for the fields being absent
|
||||
from that allowlist, which left peer workers stale."""
|
||||
import litellm.proxy.proxy_server as ps
|
||||
|
||||
# peer worker booted with the opposite/absent value
|
||||
monkeypatch.setattr(litellm, field_name, False if isinstance(db_value, bool) else None)
|
||||
|
||||
pc = ps.ProxyConfig()
|
||||
pc._update_config_fields(
|
||||
current_config={"litellm_settings": {}},
|
||||
param_name="litellm_settings",
|
||||
db_param_value={field_name: db_value},
|
||||
)
|
||||
|
||||
assert getattr(litellm, field_name) == db_value
|
||||
|
||||
|
||||
def test_get_config_list_marks_untouched_prompt_caching_flag_as_not_set(monkeypatch):
|
||||
"""The flag defaults to False rather than None, so a plain 'is not None' check would
|
||||
report the default as 'In Config' and imply an admin had set it."""
|
||||
import types
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import litellm.proxy.proxy_server as ps
|
||||
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
|
||||
from litellm.proxy.proxy_server import app
|
||||
|
||||
mock_prisma = MagicMock()
|
||||
mock_config_table = MagicMock()
|
||||
mock_config_table.find_first = AsyncMock(return_value=None)
|
||||
mock_prisma.db = types.SimpleNamespace(litellm_config=mock_config_table)
|
||||
monkeypatch.setattr(ps, "prisma_client", mock_prisma)
|
||||
monkeypatch.setattr(litellm, "enable_anthropic_prompt_caching", False)
|
||||
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
|
||||
user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN
|
||||
)
|
||||
try:
|
||||
client = TestClient(app)
|
||||
resp = client.get("/config/list", params={"config_type": "general_settings"})
|
||||
fields = {item["field_name"]: item for item in resp.json()}
|
||||
assert fields["enable_anthropic_prompt_caching"]["stored_in_db"] is None
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field_name, field_value",
|
||||
[
|
||||
("enable_anthropic_prompt_caching", True),
|
||||
("enable_anthropic_prompt_caching", False),
|
||||
("anthropic_prompt_caching_ttl", "5m"),
|
||||
("anthropic_prompt_caching_ttl", "1h"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_config_field_prompt_caching_persists_to_litellm_settings(monkeypatch, field_name, field_value):
|
||||
"""Toggling either row must set litellm.<attr> live and persist under litellm_settings,
|
||||
so the running proxy caches immediately and still does after a restart."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import litellm.proxy.proxy_server as ps
|
||||
from litellm.proxy._types import (
|
||||
ConfigFieldUpdate,
|
||||
LitellmUserRoles,
|
||||
UserAPIKeyAuth,
|
||||
)
|
||||
from litellm.proxy.proxy_server import update_config_general_settings
|
||||
|
||||
saved: dict = {}
|
||||
|
||||
async def fake_get_config():
|
||||
return {"litellm_settings": {}}
|
||||
|
||||
async def fake_save_config(new_config=None):
|
||||
saved.update(new_config or {})
|
||||
|
||||
monkeypatch.setattr(ps.proxy_config, "get_config", fake_get_config)
|
||||
monkeypatch.setattr(ps.proxy_config, "save_config", fake_save_config)
|
||||
monkeypatch.setattr(ps, "prisma_client", MagicMock())
|
||||
monkeypatch.setattr(litellm, "store_audit_logs", False)
|
||||
monkeypatch.setattr(litellm, field_name, None)
|
||||
|
||||
admin = UserAPIKeyAuth(api_key="k", user_id="a", user_role=LitellmUserRoles.PROXY_ADMIN)
|
||||
await update_config_general_settings(
|
||||
data=ConfigFieldUpdate(field_name=field_name, field_value=field_value, config_type="general_settings"),
|
||||
user_api_key_dict=admin,
|
||||
)
|
||||
|
||||
assert getattr(litellm, field_name) == field_value
|
||||
assert saved["litellm_settings"][field_name] == field_value
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field_name, bad_value",
|
||||
[
|
||||
("enable_anthropic_prompt_caching", "yes"),
|
||||
("enable_anthropic_prompt_caching", 1),
|
||||
("anthropic_prompt_caching_ttl", "10m"),
|
||||
("anthropic_prompt_caching_ttl", "1H"),
|
||||
("anthropic_prompt_caching_ttl", 3600),
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_config_field_prompt_caching_rejects_invalid(monkeypatch, field_name, bad_value):
|
||||
"""An unsupported ttl must be refused here rather than reaching Anthropic verbatim."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
import litellm.proxy.proxy_server as ps
|
||||
from litellm.proxy._types import (
|
||||
ConfigFieldUpdate,
|
||||
LitellmUserRoles,
|
||||
UserAPIKeyAuth,
|
||||
)
|
||||
from litellm.proxy.proxy_server import update_config_general_settings
|
||||
|
||||
async def fake_get_config():
|
||||
return {"litellm_settings": {}}
|
||||
|
||||
monkeypatch.setattr(ps.proxy_config, "get_config", fake_get_config)
|
||||
monkeypatch.setattr(ps, "prisma_client", MagicMock())
|
||||
monkeypatch.setattr(litellm, field_name, None)
|
||||
|
||||
admin = UserAPIKeyAuth(api_key="k", user_id="a", user_role=LitellmUserRoles.PROXY_ADMIN)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await update_config_general_settings(
|
||||
data=ConfigFieldUpdate(field_name=field_name, field_value=bad_value, config_type="general_settings"),
|
||||
user_api_key_dict=admin,
|
||||
)
|
||||
assert exc.value.status_code == 400
|
||||
assert getattr(litellm, field_name) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field_name, expected_default",
|
||||
[
|
||||
("enable_anthropic_prompt_caching", False),
|
||||
("anthropic_prompt_caching_ttl", None),
|
||||
("budget_exceeded_throttle_percentage", None),
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_config_field_restores_type_default(monkeypatch, field_name, expected_default):
|
||||
"""Reset must restore each field's own default. Blanket None would leave the boolean flag
|
||||
set to None, which is not a bool and would read as neither on nor off."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import litellm.proxy.proxy_server as ps
|
||||
from litellm.proxy._types import (
|
||||
ConfigFieldDelete,
|
||||
LitellmUserRoles,
|
||||
UserAPIKeyAuth,
|
||||
)
|
||||
from litellm.proxy.proxy_server import delete_config_general_settings
|
||||
|
||||
saved: dict = {}
|
||||
|
||||
async def fake_get_config():
|
||||
return {"litellm_settings": {field_name: "stale"}}
|
||||
|
||||
async def fake_save_config(new_config=None):
|
||||
saved.update(new_config or {})
|
||||
|
||||
monkeypatch.setattr(ps.proxy_config, "get_config", fake_get_config)
|
||||
monkeypatch.setattr(ps.proxy_config, "save_config", fake_save_config)
|
||||
monkeypatch.setattr(ps, "prisma_client", MagicMock())
|
||||
monkeypatch.setattr(litellm, "store_audit_logs", False)
|
||||
monkeypatch.setattr(litellm, field_name, "stale")
|
||||
|
||||
admin = UserAPIKeyAuth(api_key="k", user_id="a", user_role=LitellmUserRoles.PROXY_ADMIN)
|
||||
await delete_config_general_settings(
|
||||
data=ConfigFieldDelete(field_name=field_name, config_type="general_settings"),
|
||||
user_api_key_dict=admin,
|
||||
)
|
||||
|
||||
assert getattr(litellm, field_name) is expected_default
|
||||
assert field_name not in saved["litellm_settings"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad_value", [0, -0.1, 1.5, True])
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_config_field_throttle_rejects_invalid(monkeypatch, bad_value):
|
||||
|
|
|
|||
|
|
@ -1079,7 +1079,7 @@
|
|||
},
|
||||
"src/app/(dashboard)/router-settings/_components/general_settings.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 3
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
TableHeaderCell,
|
||||
TableCell,
|
||||
TableBody,
|
||||
Title,
|
||||
Text,
|
||||
Button,
|
||||
Icon,
|
||||
|
|
@ -14,13 +15,18 @@ import {
|
|||
} from "@tremor/react";
|
||||
import { TabPanel, TabPanels, TabGroup, TabList, Tab } from "@tremor/react";
|
||||
import { getGeneralSettingsCall, updateConfigFieldSetting, deleteConfigFieldSetting } from "@/components/networking";
|
||||
import { InputNumber } from "antd";
|
||||
import { InputNumber, Select as AntdSelect } from "antd";
|
||||
import { TrashIcon } from "@heroicons/react/outline";
|
||||
import { StatusBadge } from "@/components/shared/table_cells";
|
||||
|
||||
import RouterSettings from "@/components/router_settings";
|
||||
import Fallbacks from "@/components/Settings/RouterSettings/Fallbacks/Fallbacks";
|
||||
import RoutingGroups from "@/components/routing_groups";
|
||||
|
||||
const PROMPT_CACHING_TAB = "prompt_caching";
|
||||
const ENABLE_ANTHROPIC_PROMPT_CACHING = "enable_anthropic_prompt_caching";
|
||||
const ANTHROPIC_PROMPT_CACHING_TTL = "anthropic_prompt_caching_ttl";
|
||||
|
||||
interface GeneralSettingsPageProps {
|
||||
accessToken: string | null;
|
||||
userRole: string | null;
|
||||
|
|
@ -33,8 +39,117 @@ interface generalSettingsItem {
|
|||
field_value: any;
|
||||
field_description: string;
|
||||
stored_in_db: boolean | null;
|
||||
field_options?: string[] | null;
|
||||
field_tab?: string | null;
|
||||
}
|
||||
|
||||
const SettingValueEditor: React.FC<{
|
||||
setting: generalSettingsItem;
|
||||
onChange: (fieldName: string, newValue: any) => void;
|
||||
}> = ({ setting, onChange }) => {
|
||||
if (setting.field_type === "Integer") {
|
||||
return (
|
||||
<InputNumber
|
||||
step={1}
|
||||
value={setting.field_value}
|
||||
onChange={(newValue) => onChange(setting.field_name, newValue)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (setting.field_type === "Boolean") {
|
||||
return (
|
||||
<Switch
|
||||
checked={setting.field_value === true || setting.field_value === "true"}
|
||||
onChange={(checked) => onChange(setting.field_name, checked)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (setting.field_type === "Float") {
|
||||
return (
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={setting.field_value}
|
||||
onChange={(newValue) => onChange(setting.field_name, newValue)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (setting.field_type === "Select") {
|
||||
return (
|
||||
<AntdSelect
|
||||
allowClear
|
||||
style={{ minWidth: "8rem" }}
|
||||
placeholder="Default"
|
||||
value={setting.field_value || undefined}
|
||||
options={(setting.field_options ?? []).map((option) => ({ label: option, value: option }))}
|
||||
onChange={(newValue) => onChange(setting.field_name, newValue ?? "")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const PromptCachingPanel: React.FC<{
|
||||
accessToken: string;
|
||||
settings: generalSettingsItem[];
|
||||
onChange: (fieldName: string, newValue: any) => void;
|
||||
}> = ({ accessToken, settings, onChange }) => {
|
||||
const enableSetting = settings.find((s) => s.field_name === ENABLE_ANTHROPIC_PROMPT_CACHING);
|
||||
const ttlSetting = settings.find((s) => s.field_name === ANTHROPIC_PROMPT_CACHING_TTL);
|
||||
|
||||
// The two rows come from the same registry the General tab reads; if they
|
||||
// are not loaded yet there is nothing to render.
|
||||
if (!enableSetting) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const enabled = enableSetting.field_value === true || enableSetting.field_value === "true";
|
||||
|
||||
// Apply immediately: a toggle and a dropdown are direct controls, so there is
|
||||
// no separate Update button. Clearing the ttl resets it to the provider default.
|
||||
const persist = (fieldName: string, value: any) => {
|
||||
onChange(fieldName, value);
|
||||
if (value === "" || value === null || value === undefined) {
|
||||
deleteConfigFieldSetting(accessToken, fieldName);
|
||||
} else {
|
||||
updateConfigFieldSetting(accessToken, fieldName, value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Title>Prompt Caching</Title>
|
||||
|
||||
<div className="mt-6 flex items-start justify-between gap-8">
|
||||
<div className="max-w-2xl">
|
||||
<Text className="font-medium">Automatic Anthropic prompt caching</Text>
|
||||
<p className="mt-1 text-xs text-gray-500">{enableSetting.field_description}</p>
|
||||
</div>
|
||||
<Switch checked={enabled} onChange={(checked) => persist(ENABLE_ANTHROPIC_PROMPT_CACHING, checked)} />
|
||||
</div>
|
||||
|
||||
{ttlSetting && (
|
||||
<div className="mt-6 flex items-start justify-between gap-8">
|
||||
<div className="max-w-2xl">
|
||||
<Text className={`font-medium ${enabled ? "" : "text-gray-400"}`}>Cache lifetime (TTL)</Text>
|
||||
<p className="mt-1 text-xs text-gray-500">{ttlSetting.field_description}</p>
|
||||
</div>
|
||||
<AntdSelect
|
||||
allowClear
|
||||
disabled={!enabled}
|
||||
style={{ minWidth: "10rem" }}
|
||||
placeholder="5m (default)"
|
||||
value={ttlSetting.field_value || undefined}
|
||||
options={(ttlSetting.field_options ?? []).map((option) => ({ label: option, value: option }))}
|
||||
onChange={(newValue) => persist(ANTHROPIC_PROMPT_CACHING_TTL, newValue ?? "")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const GeneralSettings: React.FC<GeneralSettingsPageProps> = ({ accessToken, userRole, userID }) => {
|
||||
const [generalSettings, setGeneralSettings] = useState<generalSettingsItem[]>([]);
|
||||
|
||||
|
|
@ -108,6 +223,7 @@ const GeneralSettings: React.FC<GeneralSettingsPageProps> = ({ accessToken, user
|
|||
<Tab value="1">Loadbalancing</Tab>
|
||||
<Tab value="2">Routing Groups</Tab>
|
||||
<Tab value="3">Fallbacks</Tab>
|
||||
<Tab value="5">Prompt Caching</Tab>
|
||||
<Tab value="4">General</Tab>
|
||||
</TabList>
|
||||
<TabPanels className="px-8 py-6">
|
||||
|
|
@ -120,6 +236,9 @@ const GeneralSettings: React.FC<GeneralSettingsPageProps> = ({ accessToken, user
|
|||
<TabPanel>
|
||||
<Fallbacks accessToken={accessToken} userRole={userRole} userID={userID} />
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<PromptCachingPanel accessToken={accessToken} settings={generalSettings} onChange={handleInputChange} />
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<Card>
|
||||
<Table>
|
||||
|
|
@ -133,7 +252,7 @@ const GeneralSettings: React.FC<GeneralSettingsPageProps> = ({ accessToken, user
|
|||
</TableHead>
|
||||
<TableBody>
|
||||
{generalSettings
|
||||
.filter((value) => value.field_type !== "TypedDictionary")
|
||||
.filter((value) => value.field_type !== "TypedDictionary" && value.field_tab !== PROMPT_CACHING_TAB)
|
||||
.map((value, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell>
|
||||
|
|
@ -150,26 +269,7 @@ const GeneralSettings: React.FC<GeneralSettingsPageProps> = ({ accessToken, user
|
|||
</p>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{value.field_type == "Integer" ? (
|
||||
<InputNumber
|
||||
step={1}
|
||||
value={value.field_value}
|
||||
onChange={(newValue) => handleInputChange(value.field_name, newValue)}
|
||||
/>
|
||||
) : value.field_type == "Boolean" ? (
|
||||
<Switch
|
||||
checked={value.field_value === true || value.field_value === "true"}
|
||||
onChange={(checked) => handleInputChange(value.field_name, checked)}
|
||||
/>
|
||||
) : value.field_type == "Float" ? (
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={value.field_value}
|
||||
onChange={(newValue) => handleInputChange(value.field_name, newValue)}
|
||||
/>
|
||||
) : null}
|
||||
<SettingValueEditor setting={value} onChange={handleInputChange} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{value.stored_in_db == true ? (
|
||||
|
|
|
|||
4
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
4
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -22711,6 +22711,10 @@ export interface components {
|
|||
field_description: string;
|
||||
/** Field Name */
|
||||
field_name: string;
|
||||
/** Field Options */
|
||||
field_options?: string[] | null;
|
||||
/** Field Tab */
|
||||
field_tab?: string | null;
|
||||
/** Field Type */
|
||||
field_type: string;
|
||||
/** Field Value */
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue