- setIsCreateModalVisible(true)}>
- + Add Vector Store
-
+ {canCreateVectorStores && (
+ setIsCreateModalVisible(true)}>
+ + Add Vector Store
+
+ )}
;
+ const { accessToken, userRole, userId, isViewOnly } = useAuthorized();
+ return (
+
+ );
}
From c949843157f890ea253b51af4fa825e78543feb8 Mon Sep 17 00:00:00 2001
From: "devin-ai-integration[bot]"
<158243242+devin-ai-integration[bot]@users.noreply.github.com>
Date: Mon, 7 Sep 2026 12:24:04 -0700
Subject: [PATCH 111/123] fix(ui): send empty vector_stores when the last team
vector store is removed (#40144)
Co-authored-by: yassin
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
.../src/components/team/TeamInfo.test.tsx | 13 +++++++++++++
.../src/components/team/TeamInfo.tsx | 2 +-
2 files changed, 14 insertions(+), 1 deletion(-)
diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx
index 041ba4b548d..b286c8c1303 100644
--- a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx
+++ b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx
@@ -2052,6 +2052,19 @@ describe("TeamInfoView - the exact bytes the update call sends", () => {
expect(objectPermission.agent_access_groups).toStrictEqual([]);
});
+ it("sends an empty vector_stores array after the last vector store chip is removed", async () => {
+ const user = userEvent.setup({ delay: null });
+ await openEditor(user);
+
+ await user.click(within(screen.getByLabelText("vs-1")).getByRole("button"));
+ expect(screen.queryByLabelText("vs-1")).not.toBeInTheDocument();
+
+ const payload = await save(user);
+
+ const objectPermission = wireBody(payload).object_permission as Record;
+ expect(objectPermission.vector_stores).toStrictEqual([]);
+ });
+
it("resends every stored value once both sections are opened", async () => {
const user = userEvent.setup({ delay: null });
await openEditor(user);
diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx
index efd69a79fc0..4947cec1441 100644
--- a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx
+++ b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx
@@ -1039,7 +1039,7 @@ const TeamInfoView: React.FC = ({
delete values.agents_and_groups;
// Handle vector stores permissions
- if (values.vector_stores && values.vector_stores.length > 0) {
+ if (values.vector_stores) {
updateData.object_permission.vector_stores = values.vector_stores;
}
From 192e38fa7ba2f529cfaad3bcfc28d2613aca28d5 Mon Sep 17 00:00:00 2001
From: Yassin Kortam
Date: Mon, 7 Sep 2026 12:28:38 -0700
Subject: [PATCH 112/123] feat(skills): semantic search over the LiteLLM-hosted
skill registry (#39401)
* feat(skills): semantic search over the LiteLLM-hosted skill registry
Adds GET /v1/skills?query= (custom_llm_provider=litellm_proxy) and a
skill_search MCP virtual tool, ranking the caller's accessible skills by
semantic similarity, mirroring the A2A agent registry search (LIT-6309).
Also fixes a pre-existing bug where create_skill() dropped description and
instructions for the litellm_proxy provider, which left every LiteLLM-hosted
skill with no searchable text.
* fix(mcp): coerce skill_search top_k instead of raising 500 on malformed input
The MCP-REST skill_search dispatch validated raw tool arguments through a
pydantic model directly, so a non-numeric top_k raised a ValidationError
that the endpoint's catch-all turned into an HTTP 500. Mirrors the
agent_search branch's tolerant coerce_top_k handling instead.
* fix(skills): enforce key limits on search embeddings and bound the semantic index
Semantic search embeddings now run the same pre_call_hook the /embeddings
route runs, so key rate limits, budgets and guardrails apply before the
embedding model is called. The shared SemanticTextIndex caps cached vectors
and evicts the least recently searched entries, and each skill's embedded
text is capped so one skill cannot inflate the embedding batch
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* fix(skills): surface proxy 429s from search embeddings instead of a 503
ProxyRateLimitError is also an OpenAIError, so the search engine was folding
a key rate limit into skill_search_unavailable. Proxy HTTPExceptions now
propagate so the caller gets the same 429 the /embeddings route returns
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* fix(skills): import assert_never from typing_extensions for Python 3.10
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* fix(skills): embed the request as the pre-call hooks returned it, not the original text
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* refactor(skills): keep the litellm_proxy provider check for GET /v1/skills?query= inside llms/
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* test(skills): move the GET /v1/skills?query= endpoint tests under tests/test_litellm/proxy
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---------
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
.github/workflows/test-unit.yml | 1 +
litellm/__init__.py | 1 +
.../llms/litellm_proxy/skills/constants.py | 5 +
litellm/llms/litellm_proxy/skills/handler.py | 19 +-
.../llms/litellm_proxy/skills/skill_search.py | 161 +++++++
.../litellm_proxy/skills/transformation.py | 11 +-
.../mcp_server/rest_endpoints.py | 13 +
.../proxy/_experimental/mcp_server/server.py | 9 +
.../_experimental/mcp_server/tool_search.py | 68 ++-
litellm/proxy/_lazy_openapi_snapshot.json | 57 ++-
litellm/proxy/agent_endpoints/agent_search.py | 7 +-
litellm/proxy/agent_endpoints/endpoints.py | 3 +-
.../anthropic_endpoints/skills_endpoints.py | 97 +++-
.../proxy/common_utils/semantic_text_index.py | 79 +++-
litellm/skills/main.py | 6 +-
litellm/types/llms/anthropic_skills.py | 9 +
.../litellm_proxy/skills/test_skill_search.py | 436 ++++++++++++++++++
.../mcp_server/test_mcp_tool_search.py | 50 +-
.../agent_endpoints/test_agent_search.py | 27 +-
.../test_skills_endpoints.py | 175 +++++++
tests/test_litellm/skills/test_skills_main.py | 57 +++
ui/litellm-dashboard/src/lib/http/schema.d.ts | 14 +
22 files changed, 1259 insertions(+), 46 deletions(-)
create mode 100644 litellm/llms/litellm_proxy/skills/skill_search.py
create mode 100644 tests/test_litellm/llms/litellm_proxy/skills/test_skill_search.py
create mode 100644 tests/test_litellm/proxy/anthropic_endpoints/test_skills_endpoints.py
create mode 100644 tests/test_litellm/skills/test_skills_main.py
diff --git a/.github/workflows/test-unit.yml b/.github/workflows/test-unit.yml
index 33245ec5b5f..cc606339a20 100644
--- a/.github/workflows/test-unit.yml
+++ b/.github/workflows/test-unit.yml
@@ -116,6 +116,7 @@ jobs:
tests/test_litellm/rerank_api
tests/test_litellm/rust_bridge
tests/test_litellm/sandbox
+ tests/test_litellm/skills
tests/test_litellm/test_router
tests/test_litellm/vector_stores
tests/test_litellm/videos
diff --git a/litellm/__init__.py b/litellm/__init__.py
index 42c0ea881fd..dc2f40af46e 100644
--- a/litellm/__init__.py
+++ b/litellm/__init__.py
@@ -495,6 +495,7 @@ public_model_groups: Optional[List[str]] = None
public_agent_groups: Optional[List[str]] = None
agent_search_embedding_model: Optional[str] = None
mcp_tool_search: Optional[Mapping[str, object]] = None
+skill_search_embedding_model: Optional[str] = None
# Supports both old format (Dict[str, str]) and new format (Dict[str, Dict[str, Any]])
# New format: { "displayName": { "url": "...", "index": 0 } }
# Old format: { "displayName": "url" } (for backward compatibility)
diff --git a/litellm/llms/litellm_proxy/skills/constants.py b/litellm/llms/litellm_proxy/skills/constants.py
index a6c88718f11..04a3a7dbc91 100644
--- a/litellm/llms/litellm_proxy/skills/constants.py
+++ b/litellm/llms/litellm_proxy/skills/constants.py
@@ -16,3 +16,8 @@ DEFAULT_MAX_ITERATIONS: Final[int] = 10
DEFAULT_SANDBOX_TIMEOUT: Final[int] = 120
"""Default timeout in seconds for sandbox code execution."""
+
+MAX_SKILLS_PER_SEARCH: Final[int] = 5000
+"""Upper bound on how many of the caller's accessible skills a single semantic
+search embeds. Ranking runs in memory over this candidate set (no tsvector/DB-side
+filtering yet), so this caps worst-case embedding cost per search request."""
diff --git a/litellm/llms/litellm_proxy/skills/handler.py b/litellm/llms/litellm_proxy/skills/handler.py
index 73f6ed23092..9b625cb0571 100644
--- a/litellm/llms/litellm_proxy/skills/handler.py
+++ b/litellm/llms/litellm_proxy/skills/handler.py
@@ -6,11 +6,15 @@ Used by the transformation layer and skills injection hook.
"""
import uuid
+from collections.abc import Sequence
from typing import Final
from litellm._logging import verbose_logger
from litellm.caching.in_memory_cache import InMemoryCache
-from litellm.llms.litellm_proxy.skills.constants import LITELLM_SKILL_ID_PREFIX
+from litellm.llms.litellm_proxy.skills.constants import (
+ LITELLM_SKILL_ID_PREFIX,
+ MAX_SKILLS_PER_SEARCH,
+)
from litellm.proxy._types import LiteLLM_SkillsTable, NewSkillRequest, UserAPIKeyAuth
from litellm.proxy.common_utils.resource_ownership import (
get_primary_resource_owner_scope,
@@ -131,6 +135,19 @@ class LiteLLMSkillsHandler:
)
return [_prisma_skill_to_litellm(s) for s in skills]
+ @staticmethod
+ async def list_skills_for_search(
+ user_api_key_dict: UserAPIKeyAuth | None = None,
+ ) -> Sequence[LiteLLM_SkillsTable]:
+ """Every skill the caller can access, for ranking. Same owner-scope filter as
+ ``list_skills``, but unpaginated (up to ``MAX_SKILLS_PER_SEARCH``) since a query
+ must be scored against the whole accessible set, not one page of it."""
+ return await LiteLLMSkillsHandler.list_skills(
+ limit=MAX_SKILLS_PER_SEARCH,
+ offset=0,
+ user_api_key_dict=user_api_key_dict,
+ )
+
@staticmethod
async def _load_skill(skill_id: str) -> object | None:
"""Cache-first read of the Prisma skill row. Owner-scope filtering
diff --git a/litellm/llms/litellm_proxy/skills/skill_search.py b/litellm/llms/litellm_proxy/skills/skill_search.py
new file mode 100644
index 00000000000..f975c6c4cab
--- /dev/null
+++ b/litellm/llms/litellm_proxy/skills/skill_search.py
@@ -0,0 +1,161 @@
+"""Semantic ranking over the LiteLLM-hosted skill registry, shared by GET /v1/skills?query= and the skill_search MCP tool."""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, Final, TypeAlias
+
+from pydantic import BaseModel, ConfigDict
+
+from litellm.llms.litellm_proxy.skills.constants import MAX_SKILLS_PER_SEARCH
+from litellm.llms.litellm_proxy.skills.handler import LiteLLMSkillsHandler
+from litellm.proxy.common_utils.semantic_text_index import (
+ Embedder,
+ EmbeddingFailed,
+ SemanticTextIndex,
+ router_embedder,
+)
+from litellm.types.utils import LlmProviders
+
+if TYPE_CHECKING:
+ from litellm.proxy._types import LiteLLM_SkillsTable, UserAPIKeyAuth
+ from litellm.proxy.utils import ProxyLogging
+ from litellm.router import Router
+
+DEFAULT_SKILL_SEARCH_TOP_K: Final = 5
+MAX_SKILL_SEARCH_TOP_K: Final = 100
+"""Matches the ``le=100`` bound GET /v1/skills?query= enforces via FastAPI's Query
+validation, so the MCP tool can't return a larger payload than the REST endpoint allows."""
+MAX_SKILL_SEARCH_TEXT_CHARS: Final = 4000
+"""Per-skill cap on the title + description + instructions text that gets embedded, so one
+search embeds at most ``MAX_SKILLS_PER_SEARCH * MAX_SKILL_SEARCH_TEXT_CHARS`` characters no
+matter how long the stored instructions are."""
+
+
+@dataclass(frozen=True, slots=True)
+class SkillSearchHit:
+ skill: LiteLLM_SkillsTable
+ score: float
+
+
+@dataclass(frozen=True, slots=True)
+class SkillSearchHits:
+ hits: tuple[SkillSearchHit, ...]
+
+
+@dataclass(frozen=True, slots=True)
+class SkillSearchNotConfigured:
+ reason: str
+
+
+@dataclass(frozen=True, slots=True)
+class SkillSearchEmbeddingFailed:
+ reason: str
+
+
+@dataclass(frozen=True, slots=True)
+class SkillSearchUnsupportedProvider:
+ reason: str
+
+
+SkillSearchOutcome: TypeAlias = SkillSearchHits | SkillSearchNotConfigured | SkillSearchEmbeddingFailed
+HostedSkillSearchOutcome: TypeAlias = SkillSearchOutcome | SkillSearchUnsupportedProvider
+
+
+class SkillSearchResult(BaseModel):
+ model_config = ConfigDict(frozen=True)
+
+ skill_id: str
+ display_title: str | None
+ description: str | None
+ score: float
+
+
+def skill_search_text(skill: LiteLLM_SkillsTable) -> str:
+ joined: Final = "\n".join(part for part in (skill.display_title, skill.description, skill.instructions) if part)
+ return joined[:MAX_SKILL_SEARCH_TEXT_CHARS]
+
+
+def skill_search_result(hit: SkillSearchHit) -> SkillSearchResult:
+ return SkillSearchResult(
+ skill_id=hit.skill.skill_id,
+ display_title=hit.skill.display_title,
+ description=hit.skill.description,
+ score=hit.score,
+ )
+
+
+class SkillSearchIndex:
+ """Caches one vector per distinct skill text per embedding model, so repeat searches only embed the query."""
+
+ def __init__(self, max_entries: int = MAX_SKILLS_PER_SEARCH) -> None:
+ self._index: Final = SemanticTextIndex(max_entries=max_entries)
+
+ async def search(
+ self,
+ query: str,
+ skills: Sequence[LiteLLM_SkillsTable],
+ top_k: int,
+ embed: Embedder,
+ embedding_model: str,
+ ) -> SkillSearchHits | SkillSearchEmbeddingFailed:
+ texts: Final = tuple(skill_search_text(skill) for skill in skills)
+ scores: Final = await self._index.scores(query, texts, embed, embedding_model)
+ if isinstance(scores, EmbeddingFailed):
+ return SkillSearchEmbeddingFailed(reason=scores.reason)
+ ranked: Final = sorted(
+ (SkillSearchHit(skill=skill, score=score) for skill, score in zip(skills, scores, strict=True)),
+ key=lambda hit: hit.score,
+ reverse=True,
+ )
+ return SkillSearchHits(hits=tuple(ranked[:top_k]))
+
+
+global_skill_search_index: Final = SkillSearchIndex()
+
+
+async def search_skills(
+ query: str,
+ skills: Sequence[LiteLLM_SkillsTable],
+ top_k: int,
+ router: Router | None,
+ embedding_model: str | None,
+ index: SkillSearchIndex,
+ user_api_key_dict: UserAPIKeyAuth,
+ proxy_logging_obj: ProxyLogging,
+) -> SkillSearchOutcome:
+ if embedding_model is None:
+ return SkillSearchNotConfigured(
+ reason="skill search needs litellm_settings.skill_search_embedding_model set to an embedding model from model_list"
+ )
+ if router is None:
+ return SkillSearchNotConfigured(reason="skill search needs a model_list so the embedding model can be called")
+ embed: Final = router_embedder(router, embedding_model, user_api_key_dict, proxy_logging_obj)
+ return await index.search(query, skills, top_k, embed, embedding_model)
+
+
+async def search_hosted_skills(
+ custom_llm_provider: str | None,
+ query: str,
+ top_k: int,
+ router: Router | None,
+ embedding_model: str | None,
+ index: SkillSearchIndex,
+ user_api_key_dict: UserAPIKeyAuth,
+ proxy_logging_obj: ProxyLogging,
+) -> HostedSkillSearchOutcome:
+ """GET /v1/skills?query= for the skills LiteLLM hosts itself: only ``litellm_proxy`` has a registry to rank."""
+ if custom_llm_provider != LlmProviders.LITELLM_PROXY.value:
+ return SkillSearchUnsupportedProvider(reason="query is only supported for custom_llm_provider=litellm_proxy")
+ skills: Final = await LiteLLMSkillsHandler.list_skills_for_search(user_api_key_dict=user_api_key_dict)
+ return await search_skills(
+ query=query,
+ skills=skills,
+ top_k=top_k,
+ router=router,
+ embedding_model=embedding_model,
+ index=index,
+ user_api_key_dict=user_api_key_dict,
+ proxy_logging_obj=proxy_logging_obj,
+ )
diff --git a/litellm/llms/litellm_proxy/skills/transformation.py b/litellm/llms/litellm_proxy/skills/transformation.py
index c972dc349c9..9fc2d2cbb45 100644
--- a/litellm/llms/litellm_proxy/skills/transformation.py
+++ b/litellm/llms/litellm_proxy/skills/transformation.py
@@ -154,7 +154,7 @@ class LiteLLMSkillsTransformationHandler:
user_api_key_dict=user_api_key_dict,
)
- return self._db_skill_to_response(db_skill)
+ return self.db_skill_to_response(db_skill)
def list_skills_handler(
self,
@@ -222,7 +222,9 @@ class LiteLLMSkillsTransformationHandler:
user_api_key_dict=user_api_key_dict,
)
- skills: Final = [self._db_skill_to_response(s) for s in db_skills]
+ skills: Final = [ # mutable-ok: ListSkillsResponse.data needs list[Skill]; never mutated after
+ self.db_skill_to_response(s) for s in db_skills
+ ]
return ListSkillsResponse(
data=skills,
has_more=len(skills) >= limit,
@@ -288,7 +290,7 @@ class LiteLLMSkillsTransformationHandler:
skill_id=skill_id,
user_api_key_dict=user_api_key_dict,
)
- return self._db_skill_to_response(db_skill)
+ return self.db_skill_to_response(db_skill)
def delete_skill_handler(
self,
@@ -354,7 +356,7 @@ class LiteLLMSkillsTransformationHandler:
type=result.get("type", "skill_deleted"),
)
- def _db_skill_to_response(self, db_skill: "LiteLLM_SkillsTable") -> Skill:
+ def db_skill_to_response(self, db_skill: "LiteLLM_SkillsTable") -> Skill:
"""
Convert a database skill record to Anthropic-compatible Skill response.
@@ -375,4 +377,5 @@ class LiteLLMSkillsTransformationHandler:
latest_version=db_skill.latest_version,
source=db_skill.source or "custom",
type="skill",
+ description=db_skill.description,
)
diff --git a/litellm/proxy/_experimental/mcp_server/rest_endpoints.py b/litellm/proxy/_experimental/mcp_server/rest_endpoints.py
index 5fbfad54a39..102129ffdd0 100644
--- a/litellm/proxy/_experimental/mcp_server/rest_endpoints.py
+++ b/litellm/proxy/_experimental/mcp_server/rest_endpoints.py
@@ -104,6 +104,9 @@ if MCP_AVAILABLE:
from mcp.types import Tool as MCPTool
from litellm.experimental_mcp_client.client import MCPClient
+ from litellm.llms.litellm_proxy.skills.skill_search import (
+ DEFAULT_SKILL_SEARCH_TOP_K,
+ )
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
_UPSTREAM_OAUTH_DISCOVERY_AUTH_TYPES,
global_mcp_server_manager,
@@ -188,10 +191,12 @@ if MCP_AVAILABLE:
AGENT_SEARCH_TOOL_NAME,
DEFAULT_AGENT_SEARCH_TOP_K,
MCP_TOOL_SEARCH_TOOL_NAME,
+ SKILL_SEARCH_TOOL_NAME,
coerce_top_k,
handle_agent_search,
handle_mcp_tool_call,
handle_mcp_tool_search,
+ handle_skill_search,
)
from litellm.proxy.common_request_processing import ProxyBaseLLMRequestProcessing
from litellm.proxy.proxy_server import general_settings, proxy_config, proxy_logging_obj
@@ -210,6 +215,14 @@ if MCP_AVAILABLE:
),
user_api_key_dict=user_api_key_dict,
)
+ if tool_name == SKILL_SEARCH_TOOL_NAME:
+ return await handle_skill_search(
+ query=str(tool_arguments.get("query", "")),
+ top_k=coerce_top_k(
+ tool_arguments.get("top_k", DEFAULT_SKILL_SEARCH_TOP_K), default=DEFAULT_SKILL_SEARCH_TOP_K
+ ),
+ user_api_key_dict=user_api_key_dict,
+ )
rest_client_ip: Final = IPAddressUtils.get_mcp_client_ip(request)
(
virtual_mcp_auth_header,
diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py
index 60a9af89cc3..0b424c31c4b 100644
--- a/litellm/proxy/_experimental/mcp_server/server.py
+++ b/litellm/proxy/_experimental/mcp_server/server.py
@@ -911,15 +911,18 @@ if MCP_AVAILABLE:
Returns a CallToolResult when ``name`` is a virtual tool, else ``None`` so
the caller falls through to normal tool routing.
"""
+ from litellm.llms.litellm_proxy.skills.skill_search import DEFAULT_SKILL_SEARCH_TOP_K
from litellm.proxy._experimental.mcp_server.tool_search import (
AGENT_SEARCH_TOOL_NAME,
DEFAULT_AGENT_SEARCH_TOP_K,
MCP_TOOL_SEARCH_TOOL_NAME,
+ SKILL_SEARCH_TOOL_NAME,
VIRTUAL_TOOL_NAMES,
coerce_top_k,
handle_agent_search,
handle_mcp_tool_call,
handle_mcp_tool_search,
+ handle_skill_search,
)
if name not in VIRTUAL_TOOL_NAMES:
@@ -961,6 +964,12 @@ if MCP_AVAILABLE:
top_k=coerce_top_k(args.get("top_k", DEFAULT_AGENT_SEARCH_TOP_K), default=DEFAULT_AGENT_SEARCH_TOP_K),
user_api_key_dict=user_api_key_auth,
)
+ if name == SKILL_SEARCH_TOOL_NAME:
+ return await handle_skill_search(
+ query=str(args.get("query", "")),
+ top_k=coerce_top_k(args.get("top_k", DEFAULT_SKILL_SEARCH_TOP_K), default=DEFAULT_SKILL_SEARCH_TOP_K),
+ user_api_key_dict=user_api_key_auth,
+ )
virtual_logging_obj: Final = await _build_virtual_call_logging_obj(
name=name,
arguments=args,
diff --git a/litellm/proxy/_experimental/mcp_server/tool_search.py b/litellm/proxy/_experimental/mcp_server/tool_search.py
index af02c11ad86..f19340d30cb 100644
--- a/litellm/proxy/_experimental/mcp_server/tool_search.py
+++ b/litellm/proxy/_experimental/mcp_server/tool_search.py
@@ -11,6 +11,7 @@ from pydantic import ValidationError
from typing_extensions import ReadOnly, Required, assert_never
import litellm
+from litellm.llms.litellm_proxy.skills.skill_search import DEFAULT_SKILL_SEARCH_TOP_K
from litellm.proxy.agent_endpoints.agent_search import DEFAULT_AGENT_SEARCH_TOP_K
from litellm.proxy.common_utils.semantic_text_index import (
Embedder,
@@ -30,7 +31,10 @@ MCP_TOOL_SEARCH_SETTINGS_KEY: Final[str] = "mcp_tool_search"
MCP_TOOL_SEARCH_TOOL_NAME: Final[str] = "mcp_tool_search"
MCP_TOOL_CALL_TOOL_NAME: Final[str] = "mcp_tool_call"
AGENT_SEARCH_TOOL_NAME: Final[str] = "agent_search"
-VIRTUAL_TOOL_NAMES: Final = frozenset((MCP_TOOL_SEARCH_TOOL_NAME, MCP_TOOL_CALL_TOOL_NAME, AGENT_SEARCH_TOOL_NAME))
+SKILL_SEARCH_TOOL_NAME: Final[str] = "skill_search"
+VIRTUAL_TOOL_NAMES: Final = frozenset(
+ (MCP_TOOL_SEARCH_TOOL_NAME, MCP_TOOL_CALL_TOOL_NAME, AGENT_SEARCH_TOOL_NAME, SKILL_SEARCH_TOOL_NAME)
+)
def coerce_top_k(value: Any, default: int = 5) -> int:
@@ -199,8 +203,28 @@ _AGENT_SEARCH_DEFINITION: Final[VirtualToolDefinition] = {
}
+_SKILL_SEARCH_DEFINITION: Final[VirtualToolDefinition] = {
+ "name": SKILL_SEARCH_TOOL_NAME,
+ "description": "Find registered skills by describing what you need in natural language. Returns the best "
+ "matching skills you can access, ranked by semantic similarity, each with its skill_id, display_title, "
+ "description, and score.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "query": {"type": "string", "description": "What you need the skill to do, in natural language."},
+ "top_k": {
+ "type": "integer",
+ "description": "Maximum number of skills to return.",
+ "default": DEFAULT_SKILL_SEARCH_TOP_K,
+ },
+ },
+ "required": _json_array("query"),
+ },
+}
+
+
def get_virtual_tool_definitions() -> tuple[VirtualToolDefinition, ...]:
- return (_MCP_TOOL_SEARCH_DEFINITION, _MCP_TOOL_CALL_DEFINITION, _AGENT_SEARCH_DEFINITION)
+ return (_MCP_TOOL_SEARCH_DEFINITION, _MCP_TOOL_CALL_DEFINITION, _AGENT_SEARCH_DEFINITION, _SKILL_SEARCH_DEFINITION)
def _text_tool_result(text: str, is_error: bool) -> CallToolResult:
@@ -223,7 +247,7 @@ async def handle_agent_search(query: str, top_k: int, user_api_key_dict: UserAPI
)
from litellm.proxy.agent_endpoints.auth.agent_permission_handler import accessible_agents
from litellm.proxy.common_utils.rbac_utils import check_feature_access_for_user
- from litellm.proxy.proxy_server import llm_router
+ from litellm.proxy.proxy_server import llm_router, proxy_logging_obj
await check_feature_access_for_user(user_api_key_dict, "agents")
outcome: Final = await search_agents(
@@ -234,6 +258,7 @@ async def handle_agent_search(query: str, top_k: int, user_api_key_dict: UserAPI
embedding_model=litellm.agent_search_embedding_model,
index=global_agent_search_index,
user_api_key_dict=user_api_key_dict,
+ proxy_logging_obj=proxy_logging_obj,
)
match outcome:
case AgentSearchHits(hits):
@@ -245,6 +270,39 @@ async def handle_agent_search(query: str, top_k: int, user_api_key_dict: UserAPI
assert_never(outcome)
+async def handle_skill_search(query: str, top_k: int, user_api_key_dict: UserAPIKeyAuth) -> CallToolResult:
+ from litellm.llms.litellm_proxy.skills.handler import LiteLLMSkillsHandler
+ from litellm.llms.litellm_proxy.skills.skill_search import (
+ MAX_SKILL_SEARCH_TOP_K,
+ SkillSearchEmbeddingFailed,
+ SkillSearchHits,
+ SkillSearchNotConfigured,
+ global_skill_search_index,
+ search_skills,
+ skill_search_result,
+ )
+ from litellm.proxy.proxy_server import llm_router, proxy_logging_obj
+
+ outcome: Final = await search_skills(
+ query=query,
+ skills=await LiteLLMSkillsHandler.list_skills_for_search(user_api_key_dict),
+ top_k=min(max(top_k, 1), MAX_SKILL_SEARCH_TOP_K),
+ router=llm_router,
+ embedding_model=litellm.skill_search_embedding_model,
+ index=global_skill_search_index,
+ user_api_key_dict=user_api_key_dict,
+ proxy_logging_obj=proxy_logging_obj,
+ )
+ match outcome:
+ case SkillSearchHits(hits):
+ results: Final = tuple(skill_search_result(hit).model_dump() for hit in hits)
+ return _text_tool_result(json.dumps(results), is_error=False)
+ case SkillSearchNotConfigured(reason) | SkillSearchEmbeddingFailed(reason):
+ return _text_tool_result(reason, is_error=True)
+ case _:
+ assert_never(outcome)
+
+
async def handle_mcp_tool_search(
query: str,
top_k: int,
@@ -257,7 +315,7 @@ async def handle_mcp_tool_search(
raw_headers: dict[str, str] | None = None,
) -> CallToolResult:
from litellm.proxy._experimental.mcp_server.server import _list_mcp_tools
- from litellm.proxy.proxy_server import llm_router
+ from litellm.proxy.proxy_server import llm_router, proxy_logging_obj
settings: Final = mcp_tool_search_settings()
if isinstance(settings, ValidationError):
@@ -271,7 +329,7 @@ async def handle_mcp_tool_search(
)
ranker: Final = (
SemanticToolRanker(
- embed=router_embedder(llm_router, settings.embedding_model, user_api_key_dict),
+ embed=router_embedder(llm_router, settings.embedding_model, user_api_key_dict, proxy_logging_obj),
embedding_model=settings.embedding_model,
index=global_mcp_tool_search_index,
)
diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json
index 91a97ad6544..4093f2c5248 100644
--- a/litellm/proxy/_lazy_openapi_snapshot.json
+++ b/litellm/proxy/_lazy_openapi_snapshot.json
@@ -4687,6 +4687,17 @@
"title": "Created At",
"type": "string"
},
+ "description": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Description"
+ },
"display_title": {
"anyOf": [
{
@@ -4713,6 +4724,17 @@
],
"title": "Latest Version"
},
+ "search_score": {
+ "anyOf": [
+ {
+ "type": "number"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Search Score"
+ },
"source": {
"title": "Source",
"type": "string"
@@ -4781,7 +4803,7 @@
"paths": {
"/v1/skills": {
"get": {
- "description": "List skills on Anthropic.\n\nRequires `?beta=true` query parameter.\n\nModel-based routing (for multi-account support):\n- Pass model via header: `x-litellm-model: claude-account-1`\n- Pass model via query: `?model=claude-account-1`\n- Pass model via body: `{\"model\": \"claude-account-1\"}`\n\nExample usage:\n```bash\n# Basic usage\ncurl \"http://localhost:4000/v1/skills?beta=true&limit=10\" -H \"Authorization: Bearer your-key\"\n\n# With model-based routing\ncurl \"http://localhost:4000/v1/skills?beta=true&limit=10\" -H \"Authorization: Bearer your-key\" -H \"x-litellm-model: claude-account-1\"\n```\n\nReturns: ListSkillsResponse with list of skills",
+ "description": "List skills on Anthropic.\n\nRequires `?beta=true` query parameter.\n\nModel-based routing (for multi-account support):\n- Pass model via header: `x-litellm-model: claude-account-1`\n- Pass model via query: `?model=claude-account-1`\n- Pass model via body: `{\"model\": \"claude-account-1\"}`\n\nExample usage:\n```bash\n# Basic usage\ncurl \"http://localhost:4000/v1/skills?beta=true&limit=10\" -H \"Authorization: Bearer your-key\"\n\n# With model-based routing\ncurl \"http://localhost:4000/v1/skills?beta=true&limit=10\" -H \"Authorization: Bearer your-key\" -H \"x-litellm-model: claude-account-1\"\n```\n\nPass `?custom_llm_provider=litellm_proxy&query=` to rank the LiteLLM-hosted skills you can\naccess by semantic similarity instead of paging through the whole registry:\n```bash\ncurl \"http://localhost:4000/v1/skills?custom_llm_provider=litellm_proxy&query=summarize+a+pdf&top_k=5\" -H \"Authorization: Bearer your-key\"\n```\n\nReturns: ListSkillsResponse with list of skills",
"operationId": "list_skills_v1_skills_get",
"parameters": [
{
@@ -4849,6 +4871,39 @@
"default": "anthropic",
"title": "Custom Llm Provider"
}
+ },
+ {
+ "description": "Describe what you need in natural language to rank the skills you can access by semantic similarity over their title and description. Each result carries a search_score. Only supported for custom_llm_provider=litellm_proxy. Requires litellm_settings.skill_search_embedding_model.",
+ "in": "query",
+ "name": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "minLength": 1,
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Describe what you need in natural language to rank the skills you can access by semantic similarity over their title and description. Each result carries a search_score. Only supported for custom_llm_provider=litellm_proxy. Requires litellm_settings.skill_search_embedding_model.",
+ "title": "Query"
+ }
+ },
+ {
+ "description": "With query: the maximum number of ranked skills to return.",
+ "in": "query",
+ "name": "top_k",
+ "required": false,
+ "schema": {
+ "default": 5,
+ "description": "With query: the maximum number of ranked skills to return.",
+ "maximum": 100,
+ "minimum": 1,
+ "title": "Top K",
+ "type": "integer"
+ }
}
],
"responses": {
diff --git a/litellm/proxy/agent_endpoints/agent_search.py b/litellm/proxy/agent_endpoints/agent_search.py
index 76e3fe6c5ad..65a89bb2c7a 100644
--- a/litellm/proxy/agent_endpoints/agent_search.py
+++ b/litellm/proxy/agent_endpoints/agent_search.py
@@ -18,6 +18,7 @@ from litellm.types.agents import AgentResponse
if TYPE_CHECKING:
from litellm.proxy._types import UserAPIKeyAuth
+ from litellm.proxy.utils import ProxyLogging
from litellm.router import Router
DEFAULT_AGENT_SEARCH_TOP_K: Final = 5
@@ -132,6 +133,7 @@ async def search_agents(
embedding_model: str | None,
index: AgentSearchIndex,
user_api_key_dict: UserAPIKeyAuth,
+ proxy_logging_obj: ProxyLogging,
) -> AgentSearchOutcome:
if embedding_model is None:
return AgentSearchNotConfigured(
@@ -139,6 +141,5 @@ async def search_agents(
)
if router is None:
return AgentSearchNotConfigured(reason="agent search needs a model_list so the embedding model can be called")
- return await index.search(
- query, agents, top_k, router_embedder(router, embedding_model, user_api_key_dict), embedding_model
- )
+ embed: Final = router_embedder(router, embedding_model, user_api_key_dict, proxy_logging_obj)
+ return await index.search(query, agents, top_k, embed, embedding_model)
diff --git a/litellm/proxy/agent_endpoints/endpoints.py b/litellm/proxy/agent_endpoints/endpoints.py
index cc17672553b..aa8979a73c6 100644
--- a/litellm/proxy/agent_endpoints/endpoints.py
+++ b/litellm/proxy/agent_endpoints/endpoints.py
@@ -249,7 +249,7 @@ def _agent_search_error(status_code: int, error: str, message: str) -> HTTPExcep
async def _rank_agents_by_query(
query: str, agents: Sequence[AgentResponse], top_k: int, user_api_key_dict: UserAPIKeyAuth
) -> tuple[AgentResponse, ...]:
- from litellm.proxy.proxy_server import llm_router
+ from litellm.proxy.proxy_server import llm_router, proxy_logging_obj
outcome: Final = await search_agents(
query=query,
@@ -259,6 +259,7 @@ async def _rank_agents_by_query(
embedding_model=litellm.agent_search_embedding_model,
index=global_agent_search_index,
user_api_key_dict=user_api_key_dict,
+ proxy_logging_obj=proxy_logging_obj,
)
match outcome:
case AgentSearchHits(hits):
diff --git a/litellm/proxy/anthropic_endpoints/skills_endpoints.py b/litellm/proxy/anthropic_endpoints/skills_endpoints.py
index 9390bf4c537..4426c0b547a 100644
--- a/litellm/proxy/anthropic_endpoints/skills_endpoints.py
+++ b/litellm/proxy/anthropic_endpoints/skills_endpoints.py
@@ -2,11 +2,23 @@
Anthropic Skills API endpoints - /v1/skills
"""
-from typing import Final
+from types import MappingProxyType
+from typing import Annotated, Final
import orjson
-from fastapi import APIRouter, Depends, Request, Response
+from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
+from typing_extensions import ReadOnly, TypedDict, assert_never
+import litellm
+from litellm.llms.litellm_proxy.skills.skill_search import (
+ DEFAULT_SKILL_SEARCH_TOP_K,
+ SkillSearchEmbeddingFailed,
+ SkillSearchHits,
+ SkillSearchNotConfigured,
+ SkillSearchUnsupportedProvider,
+ global_skill_search_index,
+ search_hosted_skills,
+)
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.common_request_processing import ProxyBaseLLMRequestProcessing
@@ -23,6 +35,51 @@ from litellm.types.llms.anthropic_skills import (
router: Final = APIRouter()
+class _SkillSearchErrorDetail(TypedDict):
+ error: ReadOnly[str]
+ message: ReadOnly[str]
+
+
+def _skill_search_error(status_code: int, error: str, message: str) -> HTTPException:
+ detail: Final[_SkillSearchErrorDetail] = {"error": error, "message": message}
+ return HTTPException(status_code=status_code, detail=detail)
+
+
+async def _search_skills(
+ custom_llm_provider: str | None, query: str, top_k: int, user_api_key_dict: UserAPIKeyAuth
+) -> ListSkillsResponse:
+ from litellm.llms.litellm_proxy.skills.transformation import (
+ LiteLLMSkillsTransformationHandler,
+ )
+ from litellm.proxy.proxy_server import llm_router, proxy_logging_obj
+
+ outcome: Final = await search_hosted_skills(
+ custom_llm_provider=custom_llm_provider,
+ query=query,
+ top_k=top_k,
+ router=llm_router,
+ embedding_model=litellm.skill_search_embedding_model,
+ index=global_skill_search_index,
+ user_api_key_dict=user_api_key_dict,
+ proxy_logging_obj=proxy_logging_obj,
+ )
+ to_response: Final = LiteLLMSkillsTransformationHandler().db_skill_to_response
+ match outcome:
+ case SkillSearchHits(hits):
+ skills: Final = [ # mutable-ok: ListSkillsResponse.data requires list[Skill]; never mutated after
+ to_response(hit.skill).model_copy(update=MappingProxyType({"search_score": hit.score})) for hit in hits
+ ]
+ return ListSkillsResponse(data=skills, has_more=False, next_page=None)
+ case SkillSearchUnsupportedProvider(reason):
+ raise _skill_search_error(400, "skill_search_unsupported_provider", reason)
+ case SkillSearchNotConfigured(reason):
+ raise _skill_search_error(400, "skill_search_not_configured", reason)
+ case SkillSearchEmbeddingFailed(reason):
+ raise _skill_search_error(503, "skill_search_unavailable", reason)
+ case _:
+ assert_never(outcome)
+
+
@router.post(
"/v1/skills",
tags=["[beta] Anthropic Skills API"],
@@ -134,32 +191,58 @@ async def list_skills(
after_id: str | None = None,
before_id: str | None = None,
custom_llm_provider: str | None = "anthropic",
+ query: Annotated[
+ str | None,
+ Query(
+ min_length=1,
+ description="Describe what you need in natural language to rank the skills you can access by "
+ "semantic similarity over their title and description. Each result carries a search_score. "
+ "Only supported for custom_llm_provider=litellm_proxy. Requires "
+ "litellm_settings.skill_search_embedding_model.",
+ ),
+ ] = None,
+ top_k: Annotated[
+ int,
+ Query(ge=1, le=100, description="With query: the maximum number of ranked skills to return."),
+ ] = DEFAULT_SKILL_SEARCH_TOP_K,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
):
"""
List skills on Anthropic.
-
+
Requires `?beta=true` query parameter.
-
+
Model-based routing (for multi-account support):
- Pass model via header: `x-litellm-model: claude-account-1`
- Pass model via query: `?model=claude-account-1`
- Pass model via body: `{"model": "claude-account-1"}`
-
+
Example usage:
```bash
# Basic usage
curl "http://localhost:4000/v1/skills?beta=true&limit=10" \
-H "Authorization: Bearer your-key"
-
+
# With model-based routing
curl "http://localhost:4000/v1/skills?beta=true&limit=10" \
-H "Authorization: Bearer your-key" \
-H "x-litellm-model: claude-account-1"
```
-
+
+ Pass `?custom_llm_provider=litellm_proxy&query=` to rank the LiteLLM-hosted skills you can
+ access by semantic similarity instead of paging through the whole registry:
+ ```bash
+ curl "http://localhost:4000/v1/skills?custom_llm_provider=litellm_proxy&query=summarize+a+pdf&top_k=5" \
+ -H "Authorization: Bearer your-key"
+ ```
+
Returns: ListSkillsResponse with list of skills
"""
+ if query is not None:
+ return await _search_skills(
+ custom_llm_provider=custom_llm_provider, query=query, top_k=top_k, user_api_key_dict=user_api_key_dict
+ )
+
from litellm.proxy.proxy_server import (
general_settings,
llm_router,
diff --git a/litellm/proxy/common_utils/semantic_text_index.py b/litellm/proxy/common_utils/semantic_text_index.py
index 0820459af49..b8d3595163e 100644
--- a/litellm/proxy/common_utils/semantic_text_index.py
+++ b/litellm/proxy/common_utils/semantic_text_index.py
@@ -5,10 +5,11 @@ from __future__ import annotations
import math
from collections.abc import Awaitable, Mapping, Sequence
from dataclasses import dataclass
-from itertools import chain
+from itertools import chain, islice
from types import MappingProxyType
from typing import TYPE_CHECKING, Final, Protocol, TypeAlias
+from fastapi import HTTPException
from openai import OpenAIError
from pydantic import BaseModel, ConfigDict
@@ -16,10 +17,14 @@ from litellm.exceptions import BudgetExceededError
if TYPE_CHECKING:
from litellm.proxy._types import UserAPIKeyAuth
+ from litellm.proxy.utils import ProxyLogging
from litellm.router import Router
Vector: TypeAlias = tuple[float, ...]
+DEFAULT_MAX_CACHED_VECTORS: Final = 5000
+"""Ceiling on how many (embedding model, text) vectors one index keeps; the least recently searched are evicted first."""
+
class Embedder(Protocol):
def __call__(self, texts: Sequence[str]) -> Awaitable[Sequence[Vector]]: ...
@@ -42,6 +47,16 @@ class _EmbeddingData(BaseModel):
data: tuple[_EmbeddingItem, ...]
+class _EmbeddingRequest(BaseModel):
+ """The /embeddings-shaped request as the pre-call hooks (rate limits, budgets, guardrails) hand it back."""
+
+ model_config = ConfigDict(frozen=True, extra="ignore")
+
+ model: str
+ input: tuple[str, ...]
+ metadata: dict[str, object] # mutable-ok: the router mutates the metadata dict it is handed
+
+
def cosine_similarity(left: Vector, right: Vector) -> float:
dot: Final = sum(a * b for a, b in zip(left, right, strict=True))
norms: Final = math.sqrt(sum(a * a for a in left)) * math.sqrt(sum(b * b for b in right))
@@ -57,23 +72,40 @@ def embedding_spend_metadata(user_api_key_dict: UserAPIKeyAuth) -> dict[str, obj
}
-def router_embedder(router: Router, embedding_model: str, user_api_key_dict: UserAPIKeyAuth) -> Embedder:
+def router_embedder(
+ router: Router, embedding_model: str, user_api_key_dict: UserAPIKeyAuth, proxy_logging_obj: ProxyLogging
+) -> Embedder:
+ """Embeds through the router after the same key rate-limit, budget and guardrail pre-call hooks /embeddings runs."""
+
async def embed(texts: Sequence[str]) -> Sequence[Vector]:
- batch: Final = list(texts) # mutable-ok: Router.aembedding accepts only str | list input
+ request: Final = { # mutable-ok: pre_call_hook mutates the request dict in place
+ "model": embedding_model,
+ "input": list(texts), # mutable-ok: Router.aembedding accepts only str | list input
+ "metadata": embedding_spend_metadata(user_api_key_dict),
+ }
+ processed: Final = _EmbeddingRequest.model_validate(
+ await proxy_logging_obj.pre_call_hook(
+ user_api_key_dict=user_api_key_dict, data=request, call_type="aembedding"
+ )
+ )
response: Final = await router.aembedding(
- model=embedding_model, input=batch, metadata=embedding_spend_metadata(user_api_key_dict)
+ model=processed.model,
+ input=list(processed.input), # mutable-ok: Router.aembedding accepts only str | list input
+ metadata=processed.metadata,
)
return tuple(item.embedding for item in _EmbeddingData.model_validate(response.model_dump()).data)
return embed
-_NO_VECTORS: Final[Mapping[str, Vector]] = MappingProxyType({})
+_CacheKey: TypeAlias = tuple[str, str]
async def _embed_all(embed: Embedder, texts: Sequence[str]) -> tuple[Vector, ...] | EmbeddingFailed:
try:
vectors: Final = tuple(await embed(texts))
+ except HTTPException:
+ raise
except (OpenAIError, ValueError, BudgetExceededError) as exc:
return EmbeddingFailed(reason=f"embedding the search query failed: {exc}")
if len(vectors) != len(texts):
@@ -111,20 +143,34 @@ async def _embed_query_and_texts(
class SemanticTextIndex:
- """Caches one vector per distinct text per embedding model, so repeat searches only embed the query."""
+ """Caches one vector per distinct text per embedding model, so repeat searches only embed the query.
- def __init__(self) -> None:
- self._vectors: Mapping[str, Mapping[str, Vector]] = MappingProxyType({})
+ Holds at most ``max_entries`` vectors across all models: once full, the texts no recent search touched go first."""
- def _merged(self, embedding_model: str, embedded: _Embedded) -> Mapping[str, Vector]:
- kept: Final = MappingProxyType(
+ def __init__(self, max_entries: int = DEFAULT_MAX_CACHED_VECTORS) -> None:
+ self._max_entries: Final = max_entries
+ self._vectors: Mapping[_CacheKey, Vector] = MappingProxyType({})
+
+ def _cached(self, embedding_model: str) -> Mapping[str, Vector]:
+ return MappingProxyType(
+ {text: vector for (model, text), vector in self._vectors.items() if model == embedding_model}
+ )
+
+ def _merged(self, embedding_model: str, embedded: _Embedded, texts: Sequence[str]) -> Mapping[_CacheKey, Vector]:
+ dimension: Final = len(embedded.query_vector)
+ touched: Final = MappingProxyType({(embedding_model, text): embedded.vectors[text] for text in texts})
+ untouched: Final = MappingProxyType(
{
- text: vector
- for text, vector in self._vectors.get(embedding_model, _NO_VECTORS).items()
- if len(vector) == len(embedded.query_vector)
+ key: vector
+ for key, vector in chain(
+ self._vectors.items(),
+ (((embedding_model, text), vector) for text, vector in embedded.vectors.items()),
+ )
+ if key not in touched and (key[0] != embedding_model or len(vector) == dimension)
}
)
- return MappingProxyType({**kept, **embedded.vectors})
+ ordered: Final = MappingProxyType({**untouched, **touched})
+ return MappingProxyType(dict(islice(ordered.items(), max(len(ordered) - self._max_entries, 0), None)))
async def scores(
self, query: str, texts: Sequence[str], embed: Embedder, embedding_model: str
@@ -132,11 +178,10 @@ class SemanticTextIndex:
"""Cosine similarity of `query` to each entry of `texts`, in the same order."""
if not texts:
return ()
- cached: Final = self._vectors.get(embedding_model, _NO_VECTORS)
- embedded: Final = await _embed_query_and_texts(embed, query, texts, cached)
+ embedded: Final = await _embed_query_and_texts(embed, query, texts, self._cached(embedding_model))
if isinstance(embedded, EmbeddingFailed):
return embedded
if not _same_dimension(embedded.query_vector, embedded.vectors, texts):
return EmbeddingFailed(reason=f"embedding model {embedding_model} returned vectors of mixed dimensions")
- self._vectors = MappingProxyType({**self._vectors, embedding_model: self._merged(embedding_model, embedded)})
+ self._vectors = self._merged(embedding_model, embedded, texts)
return tuple(cosine_similarity(embedded.query_vector, embedded.vectors[text]) for text in texts)
diff --git a/litellm/skills/main.py b/litellm/skills/main.py
index 002419dbad4..71fd78f11a3 100644
--- a/litellm/skills/main.py
+++ b/litellm/skills/main.py
@@ -182,10 +182,14 @@ def create_skill(
if extra_body:
create_request.update(extra_body)
- # Route to LiteLLM DB if custom_llm_provider="litellm_proxy"
+ # Route to LiteLLM DB if custom_llm_provider="litellm_proxy". description/instructions
+ # arrive as top-level kwargs from the REST form endpoint, or nested in extra_body from
+ # the SDK convention used by other providers' create_request above.
if custom_llm_provider == LlmProviders.LITELLM_PROXY.value:
return _get_litellm_skills_handler().create_skill_handler(
display_title=display_title,
+ description=kwargs.get("description") or (extra_body.get("description") if extra_body else None),
+ instructions=kwargs.get("instructions") or (extra_body.get("instructions") if extra_body else None),
files=files,
metadata=_get_skill_request_metadata(kwargs, extra_body),
user_id=kwargs.get("user_id"),
diff --git a/litellm/types/llms/anthropic_skills.py b/litellm/types/llms/anthropic_skills.py
index 51eefe7154f..4b27f9b17ef 100644
--- a/litellm/types/llms/anthropic_skills.py
+++ b/litellm/types/llms/anthropic_skills.py
@@ -57,6 +57,15 @@ class Skill(BaseModel):
updated_at: str
"""ISO 8601 timestamp of when the skill was last updated"""
+ description: str | None = None
+ """Description of the skill. Populated for the LiteLLM-hosted registry
+ (custom_llm_provider="litellm_proxy"); Anthropic's list endpoint does not
+ return a description, so this is None there."""
+
+ search_score: float | None = None
+ """Semantic similarity to the ``query`` passed to ``GET /v1/skills``. None
+ unless a query was given."""
+
class ListSkillsResponse(BaseModel):
"""Response from listing skills"""
diff --git a/tests/test_litellm/llms/litellm_proxy/skills/test_skill_search.py b/tests/test_litellm/llms/litellm_proxy/skills/test_skill_search.py
new file mode 100644
index 00000000000..a0f22a59f0c
--- /dev/null
+++ b/tests/test_litellm/llms/litellm_proxy/skills/test_skill_search.py
@@ -0,0 +1,436 @@
+import asyncio
+import json
+from collections.abc import Sequence
+from types import MappingProxyType
+from typing import Final
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+from openai import APIConnectionError
+
+import litellm
+from litellm.llms.litellm_proxy.skills.skill_search import (
+ MAX_SKILL_SEARCH_TEXT_CHARS,
+ SkillSearchEmbeddingFailed,
+ SkillSearchHits,
+ SkillSearchIndex,
+ SkillSearchNotConfigured,
+ search_skills,
+ skill_search_text,
+)
+from litellm.proxy._types import LiteLLM_SkillsTable, UserAPIKeyAuth
+from litellm.proxy.common_utils.proxy_rate_limit_error import ProxyRateLimitError
+from litellm.proxy.common_utils.semantic_text_index import Vector, cosine_similarity
+
+CALLER: Final = UserAPIKeyAuth(api_key="hashed-caller-key", team_id="team-1", user_id="user-1")
+
+TRANSLATOR: Final = LiteLLM_SkillsTable(
+ skill_id="translate-file",
+ display_title="Document Translator",
+ description="Converts files from one language into another",
+ instructions="Take an uploaded document and produce it in the target language",
+)
+SQL_ANALYST: Final = LiteLLM_SkillsTable(
+ skill_id="warehouse-sql-analyst",
+ display_title="Warehouse SQL Analyst",
+ description="Runs SQL against the inventory database",
+)
+TRIP_PLANNER: Final = LiteLLM_SkillsTable(
+ skill_id="trip-planner",
+ display_title="Trip Planner",
+ description="Books flights and hotels",
+)
+SKILLS: Final = (TRANSLATOR, SQL_ANALYST, TRIP_PLANNER)
+
+VECTORS: Final = MappingProxyType(
+ {
+ "language translation": (1.0, 0.0, 0.0),
+ skill_search_text(TRANSLATOR): (0.9, 0.1, 0.0),
+ skill_search_text(SQL_ANALYST): (0.0, 1.0, 0.0),
+ skill_search_text(TRIP_PLANNER): (0.3, 0.0, 1.0),
+ }
+)
+
+
+def _pass_through_key_limits() -> MagicMock:
+ limits = MagicMock()
+ limits.pre_call_hook = AsyncMock(side_effect=lambda user_api_key_dict, data, call_type: data)
+ return limits
+
+
+def _embedding_router() -> MagicMock:
+ router = MagicMock()
+ router.aembedding = AsyncMock(
+ side_effect=lambda model, input, metadata: litellm.EmbeddingResponse(
+ model=model,
+ data=[{"object": "embedding", "index": i, "embedding": list(VECTORS[t])} for i, t in enumerate(input)],
+ )
+ )
+ return router
+
+
+class FakeEmbedder:
+ def __init__(self) -> None:
+ self.calls: list[tuple[str, ...]] = [] # mutable-ok: test spy recording embed inputs
+
+ async def __call__(self, texts: Sequence[str]) -> Sequence[Vector]:
+ self.calls.append(tuple(texts))
+ return tuple(VECTORS[text] for text in texts)
+
+
+class FixedDimensionEmbedder:
+ def __init__(self, dimensions: int) -> None:
+ self.dimensions: Final = dimensions
+ self.calls: list[tuple[str, ...]] = [] # mutable-ok: test spy recording embed inputs
+
+ async def __call__(self, texts: Sequence[str]) -> Sequence[Vector]:
+ self.calls.append(tuple(texts))
+ await asyncio.sleep(0)
+ return tuple((1.0,) * self.dimensions for _ in texts)
+
+
+class TestSkillSearchText:
+ def test_joins_title_description_and_instructions(self) -> None:
+ assert skill_search_text(TRANSLATOR) == (
+ "Document Translator\n"
+ "Converts files from one language into another\n"
+ "Take an uploaded document and produce it in the target language"
+ )
+
+ def test_missing_fields_fall_back_to_whatever_is_present(self) -> None:
+ assert skill_search_text(LiteLLM_SkillsTable(skill_id="bare", display_title="bare")) == "bare"
+
+ def test_all_fields_absent_is_an_empty_string(self) -> None:
+ assert skill_search_text(LiteLLM_SkillsTable(skill_id="empty")) == ""
+
+ def test_oversized_instructions_are_cut_so_one_skill_cannot_blow_up_the_embedding_batch(self) -> None:
+ bloated = LiteLLM_SkillsTable(
+ skill_id="bloated", display_title="Bloated", instructions="x" * (MAX_SKILL_SEARCH_TEXT_CHARS * 3)
+ )
+ text = skill_search_text(bloated)
+ assert len(text) == MAX_SKILL_SEARCH_TEXT_CHARS
+ assert text.startswith("Bloated\n")
+
+
+class TestCosineSimilarity:
+ def test_identical_direction_scores_one(self) -> None:
+ assert cosine_similarity((2.0, 0.0), (1.0, 0.0)) == pytest.approx(1.0)
+
+ def test_orthogonal_scores_zero(self) -> None:
+ assert cosine_similarity((1.0, 0.0), (0.0, 1.0)) == pytest.approx(0.0)
+
+ def test_zero_vector_scores_zero_instead_of_dividing(self) -> None:
+ assert cosine_similarity((0.0, 0.0), (1.0, 0.0)) == 0.0
+
+
+class TestSkillSearchIndex:
+ @pytest.mark.asyncio
+ async def test_ranks_by_similarity_and_truncates_to_top_k(self) -> None:
+ outcome = await SkillSearchIndex().search(
+ "language translation", SKILLS, top_k=2, embed=FakeEmbedder(), embedding_model="m"
+ )
+ assert isinstance(outcome, SkillSearchHits)
+ assert [hit.skill.skill_id for hit in outcome.hits] == ["translate-file", "trip-planner"]
+ assert outcome.hits[0].score > outcome.hits[1].score
+
+ @pytest.mark.asyncio
+ async def test_second_search_only_embeds_the_query(self) -> None:
+ index = SkillSearchIndex()
+ embedder = FakeEmbedder()
+ await index.search("language translation", SKILLS, top_k=5, embed=embedder, embedding_model="m")
+ await index.search("language translation", SKILLS, top_k=5, embed=embedder, embedding_model="m")
+ assert len(embedder.calls[0]) == 1 + len(SKILLS)
+ assert embedder.calls[1] == ("language translation",)
+
+ @pytest.mark.asyncio
+ async def test_switching_embedding_models_does_not_reuse_cached_vectors(self) -> None:
+ index = SkillSearchIndex()
+ await index.search("language translation", SKILLS, top_k=5, embed=FakeEmbedder(), embedding_model="small")
+ wide = FixedDimensionEmbedder(2)
+ outcome = await index.search("language translation", SKILLS, top_k=5, embed=wide, embedding_model="wide")
+ assert isinstance(outcome, SkillSearchHits)
+ assert len(wide.calls[0]) == 1 + len(SKILLS)
+
+ @pytest.mark.asyncio
+ async def test_cached_vectors_of_another_dimension_are_re_embedded(self) -> None:
+ index = SkillSearchIndex()
+ await index.search("language translation", SKILLS, top_k=5, embed=FakeEmbedder(), embedding_model="m")
+ fallback = FixedDimensionEmbedder(2)
+ outcome = await index.search("language translation", SKILLS, top_k=5, embed=fallback, embedding_model="m")
+ assert isinstance(outcome, SkillSearchHits)
+ assert fallback.calls == [
+ ("language translation",),
+ ("language translation", *(skill_search_text(skill) for skill in SKILLS)),
+ ]
+
+ @pytest.mark.asyncio
+ async def test_re_embedding_a_subset_drops_the_other_skills_old_vectors(self) -> None:
+ index = SkillSearchIndex()
+ await index.search("language translation", SKILLS, top_k=5, embed=FakeEmbedder(), embedding_model="m")
+ wide = FixedDimensionEmbedder(2)
+ await index.search("language translation", SKILLS[:1], top_k=5, embed=wide, embedding_model="m")
+ await index.search("language translation", SKILLS, top_k=5, embed=wide, embedding_model="m")
+ assert wide.calls[-1] == ("language translation", *(skill_search_text(skill) for skill in SKILLS[1:]))
+
+ @pytest.mark.asyncio
+ async def test_concurrent_searches_keep_each_others_vectors(self) -> None:
+ index = SkillSearchIndex()
+ embedder = FixedDimensionEmbedder(3)
+ await asyncio.gather(
+ index.search("q", SKILLS[:1], top_k=5, embed=embedder, embedding_model="m"),
+ index.search("q", SKILLS[1:], top_k=5, embed=embedder, embedding_model="m"),
+ )
+ await index.search("q", SKILLS, top_k=5, embed=embedder, embedding_model="m")
+ assert embedder.calls[-1] == ("q",)
+
+ @pytest.mark.asyncio
+ async def test_least_recently_searched_skills_are_evicted_once_the_index_is_full(self) -> None:
+ index = SkillSearchIndex(max_entries=len(SKILLS))
+ embedder = FixedDimensionEmbedder(3)
+ newcomer = LiteLLM_SkillsTable(skill_id="newcomer", display_title="Newcomer")
+ await index.search("q", SKILLS, top_k=5, embed=embedder, embedding_model="m")
+ await index.search("q", SKILLS[:1], top_k=5, embed=embedder, embedding_model="m")
+ await index.search("q", (newcomer,), top_k=5, embed=embedder, embedding_model="m")
+ await index.search("q", SKILLS, top_k=5, embed=embedder, embedding_model="m")
+ assert embedder.calls[-1] == ("q", skill_search_text(SKILLS[1]))
+
+ @pytest.mark.asyncio
+ async def test_deleted_skills_stop_occupying_the_index_after_enough_new_ones(self) -> None:
+ index = SkillSearchIndex(max_entries=2)
+ embedder = FixedDimensionEmbedder(3)
+ for generation in range(50):
+ skill = LiteLLM_SkillsTable(skill_id=f"gen-{generation}", display_title=f"Generation {generation}")
+ await index.search("q", (skill,), top_k=5, embed=embedder, embedding_model="m")
+ await index.search("q", SKILLS, top_k=5, embed=embedder, embedding_model="m")
+ await index.search("q", SKILLS, top_k=5, embed=embedder, embedding_model="m")
+ assert embedder.calls[-1] == ("q", skill_search_text(SKILLS[0]))
+
+ @pytest.mark.asyncio
+ async def test_mixed_dimensions_in_one_batch_become_embedding_failed(self) -> None:
+ async def mixed(texts: Sequence[str]) -> Sequence[Vector]:
+ return ((1.0, 0.0), *((1.0, 0.0, 0.0) for _ in texts[1:]))
+
+ outcome = await SkillSearchIndex().search("q", SKILLS, top_k=5, embed=mixed, embedding_model="m")
+ assert isinstance(outcome, SkillSearchEmbeddingFailed)
+ assert "mixed dimensions" in outcome.reason
+
+ @pytest.mark.asyncio
+ async def test_no_accessible_skills_returns_no_hits_without_embedding(self) -> None:
+ embedder = FakeEmbedder()
+ outcome = await SkillSearchIndex().search("anything", (), top_k=5, embed=embedder, embedding_model="m")
+ assert outcome == SkillSearchHits(hits=())
+ assert embedder.calls == []
+
+ @pytest.mark.asyncio
+ async def test_provider_error_becomes_embedding_failed(self) -> None:
+ async def failing(texts: Sequence[str]) -> Sequence[Vector]:
+ raise APIConnectionError(request=MagicMock())
+
+ outcome = await SkillSearchIndex().search("q", SKILLS, top_k=5, embed=failing, embedding_model="m")
+ assert isinstance(outcome, SkillSearchEmbeddingFailed)
+ assert "embedding the search query failed" in outcome.reason
+
+ @pytest.mark.asyncio
+ async def test_wrong_vector_count_becomes_embedding_failed(self) -> None:
+ async def short(texts: Sequence[str]) -> Sequence[Vector]:
+ return ((1.0, 0.0, 0.0),)
+
+ outcome = await SkillSearchIndex().search("q", SKILLS, top_k=5, embed=short, embedding_model="m")
+ assert isinstance(outcome, SkillSearchEmbeddingFailed)
+
+
+class TestSearchSkills:
+ @pytest.mark.asyncio
+ async def test_no_embedding_model_is_not_configured(self) -> None:
+ outcome = await search_skills(
+ "q",
+ SKILLS,
+ 5,
+ router=MagicMock(),
+ embedding_model=None,
+ index=SkillSearchIndex(),
+ user_api_key_dict=CALLER,
+ proxy_logging_obj=_pass_through_key_limits(),
+ )
+ assert isinstance(outcome, SkillSearchNotConfigured)
+ assert "skill_search_embedding_model" in outcome.reason
+
+ @pytest.mark.asyncio
+ async def test_no_router_is_not_configured(self) -> None:
+ outcome = await search_skills(
+ "q",
+ SKILLS,
+ 5,
+ router=None,
+ embedding_model="m",
+ index=SkillSearchIndex(),
+ user_api_key_dict=CALLER,
+ proxy_logging_obj=_pass_through_key_limits(),
+ )
+ assert isinstance(outcome, SkillSearchNotConfigured)
+
+ @pytest.mark.asyncio
+ async def test_router_embeddings_are_read_from_the_response(self) -> None:
+ router = _embedding_router()
+ outcome = await search_skills(
+ "language translation",
+ SKILLS,
+ 1,
+ router=router,
+ embedding_model="text-embedding-3-small",
+ index=SkillSearchIndex(),
+ user_api_key_dict=CALLER,
+ proxy_logging_obj=_pass_through_key_limits(),
+ )
+ assert isinstance(outcome, SkillSearchHits)
+ assert [hit.skill.skill_id for hit in outcome.hits] == ["translate-file"]
+ assert router.aembedding.await_args.kwargs["model"] == "text-embedding-3-small"
+
+ @pytest.mark.asyncio
+ async def test_embedding_spend_is_attributed_to_the_calling_key(self) -> None:
+ router = _embedding_router()
+ await search_skills(
+ "language translation",
+ SKILLS,
+ 1,
+ router=router,
+ embedding_model="text-embedding-3-small",
+ index=SkillSearchIndex(),
+ user_api_key_dict=CALLER,
+ proxy_logging_obj=_pass_through_key_limits(),
+ )
+ metadata = router.aembedding.await_args.kwargs["metadata"]
+ assert metadata["user_api_key"] == "hashed-caller-key"
+ assert metadata["user_api_key_team_id"] == "team-1"
+ assert metadata["user_api_key_user_id"] == "user-1"
+
+ @pytest.mark.asyncio
+ async def test_key_limits_are_checked_against_the_real_embedding_call_before_it_runs(self) -> None:
+ router = _embedding_router()
+ key_limits = _pass_through_key_limits()
+ await search_skills(
+ "language translation",
+ SKILLS,
+ 1,
+ router=router,
+ embedding_model="text-embedding-3-small",
+ index=SkillSearchIndex(),
+ user_api_key_dict=CALLER,
+ proxy_logging_obj=key_limits,
+ )
+ checked = key_limits.pre_call_hook.await_args.kwargs
+ assert checked["user_api_key_dict"] is CALLER
+ assert checked["call_type"] == "aembedding"
+ assert checked["data"]["model"] == "text-embedding-3-small"
+ assert checked["data"]["input"] == router.aembedding.await_args.kwargs["input"]
+ assert checked["data"]["metadata"]["user_api_key"] == "hashed-caller-key"
+
+ @pytest.mark.asyncio
+ async def test_the_embedding_model_sees_the_request_as_the_guardrails_rewrote_it(self) -> None:
+ router = MagicMock()
+ router.aembedding = AsyncMock(
+ side_effect=lambda model, input, metadata: litellm.EmbeddingResponse(
+ model=model,
+ data=[{"object": "embedding", "index": i, "embedding": [1.0, 0.0, 0.0]} for i in range(len(input))],
+ )
+ )
+ key_limits = MagicMock()
+ key_limits.pre_call_hook = AsyncMock(
+ side_effect=lambda user_api_key_dict, data, call_type: {
+ **data,
+ "input": ["[MASKED]" for _ in data["input"]],
+ "metadata": {**data["metadata"], "guardrail": "masked"},
+ }
+ )
+ await search_skills(
+ "language translation",
+ SKILLS,
+ 1,
+ router=router,
+ embedding_model="text-embedding-3-small",
+ index=SkillSearchIndex(),
+ user_api_key_dict=CALLER,
+ proxy_logging_obj=key_limits,
+ )
+ sent = tuple(call.kwargs for call in router.aembedding.await_args_list)
+ assert sent
+ assert all(set(call["input"]) == {"[MASKED]"} for call in sent)
+ assert all(call["metadata"]["guardrail"] == "masked" for call in sent)
+
+ @pytest.mark.asyncio
+ async def test_a_key_over_its_limit_never_reaches_the_embedding_model(self) -> None:
+ router = _embedding_router()
+ key_limits = MagicMock()
+ key_limits.pre_call_hook = AsyncMock(side_effect=ProxyRateLimitError(detail="rpm exceeded"))
+ with pytest.raises(ProxyRateLimitError) as raised:
+ await search_skills(
+ "language translation",
+ SKILLS,
+ 1,
+ router=router,
+ embedding_model="text-embedding-3-small",
+ index=SkillSearchIndex(),
+ user_api_key_dict=CALLER,
+ proxy_logging_obj=key_limits,
+ )
+ assert raised.value.status_code == 429
+ router.aembedding.assert_not_awaited()
+
+
+@pytest.fixture
+def accessible_skills(monkeypatch: pytest.MonkeyPatch) -> AsyncMock:
+ list_for_search = AsyncMock(return_value=list(SKILLS))
+ monkeypatch.setattr(
+ "litellm.llms.litellm_proxy.skills.handler.LiteLLMSkillsHandler.list_skills_for_search", list_for_search
+ )
+ return list_for_search
+
+
+@pytest.fixture
+def key_limits(monkeypatch: pytest.MonkeyPatch) -> MagicMock:
+ limits = _pass_through_key_limits()
+ monkeypatch.setattr("litellm.proxy.proxy_server.proxy_logging_obj", limits)
+ return limits
+
+
+@pytest.fixture
+def embedding_router(monkeypatch: pytest.MonkeyPatch, key_limits: MagicMock) -> MagicMock:
+ router = _embedding_router()
+ monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", router)
+ monkeypatch.setattr(litellm, "skill_search_embedding_model", "text-embedding-3-small")
+ return router
+
+
+class TestHandleSkillSearchMCP:
+ @pytest.mark.asyncio
+ async def test_top_k_is_clamped_to_the_same_ceiling_as_rest(
+ self, accessible_skills: AsyncMock, embedding_router: MagicMock
+ ) -> None:
+ from litellm.llms.litellm_proxy.skills.skill_search import MAX_SKILL_SEARCH_TOP_K
+ from litellm.proxy._experimental.mcp_server.tool_search import handle_skill_search
+
+ many_skills: Final = tuple(
+ LiteLLM_SkillsTable(
+ skill_id=f"skill-{i}", display_title=TRIP_PLANNER.display_title, description=TRIP_PLANNER.description
+ )
+ for i in range(MAX_SKILL_SEARCH_TOP_K + 50)
+ )
+ accessible_skills.return_value = list(many_skills)
+
+ result = await handle_skill_search(
+ query="language translation", top_k=10_000, user_api_key_dict=UserAPIKeyAuth(user_id="u")
+ )
+ assert result.isError is False
+ assert len(json.loads(result.content[0].text)) == MAX_SKILL_SEARCH_TOP_K
+
+ @pytest.mark.asyncio
+ async def test_top_k_below_one_is_raised_to_one(
+ self, accessible_skills: AsyncMock, embedding_router: MagicMock
+ ) -> None:
+ from litellm.proxy._experimental.mcp_server.tool_search import handle_skill_search
+
+ result = await handle_skill_search(
+ query="language translation", top_k=0, user_api_key_dict=UserAPIKeyAuth(user_id="u")
+ )
+ assert result.isError is False
+ assert len(json.loads(result.content[0].text)) == 1
diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_tool_search.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_tool_search.py
index 239f89ebd90..798b0001af1 100644
--- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_tool_search.py
+++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_tool_search.py
@@ -24,6 +24,7 @@ from litellm.proxy._experimental.mcp_server.tool_search import (
AGENT_SEARCH_TOOL_NAME,
MCP_TOOL_CALL_TOOL_NAME,
MCP_TOOL_SEARCH_TOOL_NAME,
+ SKILL_SEARCH_TOOL_NAME,
SemanticToolRanker,
ToolSearchResult,
coerce_top_k,
@@ -272,8 +273,8 @@ class TestSearchTools:
class TestGetVirtualToolDefinitions:
- def test_returns_three_tools(self) -> None:
- assert len(get_virtual_tool_definitions()) == 3
+ def test_returns_four_tools(self) -> None:
+ assert len(get_virtual_tool_definitions()) == 4
def test_agent_search_schema_requires_query(self) -> None:
tools = get_virtual_tool_definitions()
@@ -330,6 +331,7 @@ class TestGetVirtualToolDefinitions:
MCP_TOOL_SEARCH_TOOL_NAME,
MCP_TOOL_CALL_TOOL_NAME,
AGENT_SEARCH_TOOL_NAME,
+ SKILL_SEARCH_TOOL_NAME,
}
@@ -364,7 +366,12 @@ class TestListToolRestApiWithToolSearch:
assert result["error"] is None
tool_names = [t["name"] for t in result["tools"]]
- assert set(tool_names) == {MCP_TOOL_SEARCH_TOOL_NAME, MCP_TOOL_CALL_TOOL_NAME, AGENT_SEARCH_TOOL_NAME}
+ assert set(tool_names) == {
+ MCP_TOOL_SEARCH_TOOL_NAME,
+ MCP_TOOL_CALL_TOOL_NAME,
+ AGENT_SEARCH_TOOL_NAME,
+ SKILL_SEARCH_TOOL_NAME,
+ }
@pytest.mark.asyncio
async def test_returns_full_catalog_when_flag_disabled(self) -> None:
@@ -737,6 +744,31 @@ class TestCallToolRestApiVirtualTools:
assert mock_search.await_args.kwargs["top_k"] == 1
assert mock_search.await_args.kwargs["agents"] == (translator,)
+ @pytest.mark.asyncio
+ async def test_skill_search_call_tolerates_malformed_top_k(self) -> None:
+ """Regression: a caller-supplied non-numeric top_k must be coerced to the default,
+ the same as agent_search, instead of raising a pydantic ValidationError that the
+ endpoint's catch-all turns into an HTTP 500."""
+ from mcp.types import CallToolResult, TextContent
+
+ from litellm.llms.litellm_proxy.skills.skill_search import DEFAULT_SKILL_SEARCH_TOP_K
+
+ user_api_key_dict = UserAPIKeyAuth(api_key="k", object_permission=_make_perm(mcp_tool_search_enabled=True))
+ request = self._make_request(
+ {"name": SKILL_SEARCH_TOOL_NAME, "arguments": {"query": "translate a document", "top_k": "not-a-number"}}
+ )
+ fake_result = CallToolResult(content=[TextContent(type="text", text="[]")], isError=False)
+ with patch( # test-quality-ok: the embedding router only resolves via proxy_server globals, no injection seam
+ "litellm.proxy._experimental.mcp_server.tool_search.handle_skill_search",
+ new_callable=AsyncMock,
+ return_value=fake_result,
+ ) as mock_search:
+ result = await self._get_call_fn()(request=request, user_api_key_dict=user_api_key_dict)
+
+ assert result.isError is False
+ assert mock_search.await_args.kwargs["top_k"] == DEFAULT_SKILL_SEARCH_TOP_K
+ assert mock_search.await_args.kwargs["query"] == "translate a document"
+
@pytest.mark.asyncio
async def test_agent_search_call_reports_missing_embedding_model_as_tool_error(self) -> None:
from litellm.proxy.agent_endpoints.agent_search import AgentSearchNotConfigured
@@ -782,10 +814,15 @@ class TestCallToolRestApiVirtualTools:
router = MagicMock()
router.aembedding = AsyncMock(side_effect=fake_aembedding)
+ key_limits = MagicMock()
+ key_limits.pre_call_hook = AsyncMock(side_effect=lambda user_api_key_dict, data, call_type: data)
with (
patch( # test-quality-ok: the proxy's router is a module global; the handler reaches it the way production does
"litellm.proxy.proxy_server.llm_router", router
),
+ patch( # test-quality-ok: the proxy's key-limit hooks are a module global; the embedding call runs them like /embeddings does
+ "litellm.proxy.proxy_server.proxy_logging_obj", key_limits
+ ),
patch( # test-quality-ok: the authorized catalog is the seam every virtual tool shares; the ranking under test stays real
"litellm.proxy._experimental.mcp_server.server._list_mcp_tools",
new_callable=AsyncMock,
@@ -795,6 +832,8 @@ class TestCallToolRestApiVirtualTools:
result = await self._get_call_fn()(request=self._semantic_request(), user_api_key_dict=user_api_key_dict)
assert mock_list.await_args.kwargs["user_api_key_auth"] is user_api_key_dict
+ assert key_limits.pre_call_hook.await_args.kwargs["call_type"] == "aembedding"
+ assert key_limits.pre_call_hook.await_args.kwargs["data"]["model"] == "emb"
assert result.isError is False
assert [t["name"] for t in json.loads(result.content[0].text)] == [FX_TOOL.name]
@@ -810,7 +849,9 @@ class TestCallToolRestApiVirtualTools:
assert "mcp_tool_search.embedding_model" in result.content[0].text
@pytest.mark.asyncio
- async def test_mcp_tool_search_reports_invalid_settings_as_tool_error(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ async def test_mcp_tool_search_reports_invalid_settings_as_tool_error(
+ self, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
monkeypatch.setattr(litellm, "mcp_tool_search", {"top_k": 0})
user_api_key_dict = UserAPIKeyAuth(api_key="k", object_permission=_make_perm(mcp_tool_search_enabled=True))
result = await self._get_call_fn()(request=self._semantic_request(), user_api_key_dict=user_api_key_dict)
@@ -1196,6 +1237,7 @@ class TestHandleListToolsVirtual:
MCP_TOOL_SEARCH_TOOL_NAME,
MCP_TOOL_CALL_TOOL_NAME,
AGENT_SEARCH_TOOL_NAME,
+ SKILL_SEARCH_TOOL_NAME,
}
diff --git a/tests/test_litellm/proxy/agent_endpoints/test_agent_search.py b/tests/test_litellm/proxy/agent_endpoints/test_agent_search.py
index daca244c0a1..c02ed1f37e5 100644
--- a/tests/test_litellm/proxy/agent_endpoints/test_agent_search.py
+++ b/tests/test_litellm/proxy/agent_endpoints/test_agent_search.py
@@ -211,11 +211,24 @@ class TestAgentSearchIndex:
assert isinstance(outcome, AgentSearchEmbeddingFailed)
+def _pass_through_key_limits() -> MagicMock:
+ limits = MagicMock()
+ limits.pre_call_hook = AsyncMock(side_effect=lambda user_api_key_dict, data, call_type: data)
+ return limits
+
+
class TestSearchAgents:
@pytest.mark.asyncio
async def test_no_embedding_model_is_not_configured(self) -> None:
outcome = await search_agents(
- "q", AGENTS, 5, router=MagicMock(), embedding_model=None, index=AgentSearchIndex(), user_api_key_dict=CALLER
+ "q",
+ AGENTS,
+ 5,
+ router=MagicMock(),
+ embedding_model=None,
+ index=AgentSearchIndex(),
+ user_api_key_dict=CALLER,
+ proxy_logging_obj=_pass_through_key_limits(),
)
assert isinstance(outcome, AgentSearchNotConfigured)
assert "agent_search_embedding_model" in outcome.reason
@@ -223,7 +236,14 @@ class TestSearchAgents:
@pytest.mark.asyncio
async def test_no_router_is_not_configured(self) -> None:
outcome = await search_agents(
- "q", AGENTS, 5, router=None, embedding_model="m", index=AgentSearchIndex(), user_api_key_dict=CALLER
+ "q",
+ AGENTS,
+ 5,
+ router=None,
+ embedding_model="m",
+ index=AgentSearchIndex(),
+ user_api_key_dict=CALLER,
+ proxy_logging_obj=_pass_through_key_limits(),
)
assert isinstance(outcome, AgentSearchNotConfigured)
@@ -244,6 +264,7 @@ class TestSearchAgents:
embedding_model="text-embedding-3-small",
index=AgentSearchIndex(),
user_api_key_dict=CALLER,
+ proxy_logging_obj=_pass_through_key_limits(),
)
assert isinstance(outcome, AgentSearchHits)
assert [hit.agent.agent_id for hit in outcome.hits] == ["translator"]
@@ -266,6 +287,7 @@ class TestSearchAgents:
embedding_model="text-embedding-3-small",
index=AgentSearchIndex(),
user_api_key_dict=CALLER,
+ proxy_logging_obj=_pass_through_key_limits(),
)
metadata = router.aembedding.await_args.kwargs["metadata"]
assert metadata["user_api_key"] == "hashed-caller-key"
@@ -302,6 +324,7 @@ def embedding_router(monkeypatch: pytest.MonkeyPatch) -> MagicMock:
)
)
monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", router)
+ monkeypatch.setattr("litellm.proxy.proxy_server.proxy_logging_obj", _pass_through_key_limits())
monkeypatch.setattr(litellm, "agent_search_embedding_model", "text-embedding-3-small")
return router
diff --git a/tests/test_litellm/proxy/anthropic_endpoints/test_skills_endpoints.py b/tests/test_litellm/proxy/anthropic_endpoints/test_skills_endpoints.py
new file mode 100644
index 00000000000..ae6b1471c93
--- /dev/null
+++ b/tests/test_litellm/proxy/anthropic_endpoints/test_skills_endpoints.py
@@ -0,0 +1,175 @@
+from types import MappingProxyType
+from typing import Final
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+from fastapi import FastAPI
+from fastapi.testclient import TestClient
+from openai import APIConnectionError
+
+import litellm
+from litellm.llms.litellm_proxy.skills.skill_search import skill_search_text
+from litellm.proxy._types import LiteLLM_SkillsTable, LitellmUserRoles, UserAPIKeyAuth
+from litellm.proxy.anthropic_endpoints.skills_endpoints import router
+from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
+from litellm.proxy.common_utils.proxy_rate_limit_error import ProxyRateLimitError
+
+TRANSLATOR: Final = LiteLLM_SkillsTable(
+ skill_id="translate-file",
+ display_title="Document Translator",
+ description="Converts files from one language into another",
+ instructions="Take an uploaded document and produce it in the target language",
+)
+SQL_ANALYST: Final = LiteLLM_SkillsTable(
+ skill_id="warehouse-sql-analyst",
+ display_title="Warehouse SQL Analyst",
+ description="Runs SQL against the inventory database",
+)
+TRIP_PLANNER: Final = LiteLLM_SkillsTable(
+ skill_id="trip-planner",
+ display_title="Trip Planner",
+ description="Books flights and hotels",
+)
+SKILLS: Final = (TRANSLATOR, SQL_ANALYST, TRIP_PLANNER)
+
+VECTORS: Final = MappingProxyType(
+ {
+ "language translation": (1.0, 0.0, 0.0),
+ skill_search_text(TRANSLATOR): (0.9, 0.1, 0.0),
+ skill_search_text(SQL_ANALYST): (0.0, 1.0, 0.0),
+ skill_search_text(TRIP_PLANNER): (0.3, 0.0, 1.0),
+ }
+)
+
+
+def _client(role: LitellmUserRoles) -> TestClient:
+ app = FastAPI()
+ app.include_router(router)
+ app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(user_id="u", user_role=role)
+ return TestClient(app)
+
+
+@pytest.fixture
+def accessible_skills(monkeypatch: pytest.MonkeyPatch) -> AsyncMock:
+ list_for_search = AsyncMock(return_value=list(SKILLS))
+ monkeypatch.setattr(
+ "litellm.llms.litellm_proxy.skills.handler.LiteLLMSkillsHandler.list_skills_for_search", list_for_search
+ )
+ return list_for_search
+
+
+@pytest.fixture
+def key_limits(monkeypatch: pytest.MonkeyPatch) -> MagicMock:
+ limits = MagicMock()
+ limits.pre_call_hook = AsyncMock(side_effect=lambda user_api_key_dict, data, call_type: data)
+ monkeypatch.setattr("litellm.proxy.proxy_server.proxy_logging_obj", limits)
+ return limits
+
+
+@pytest.fixture
+def embedding_router(monkeypatch: pytest.MonkeyPatch, key_limits: MagicMock) -> MagicMock:
+ embedding_router = MagicMock()
+ embedding_router.aembedding = AsyncMock(
+ side_effect=lambda model, input, metadata: litellm.EmbeddingResponse(
+ model=model,
+ data=[{"object": "embedding", "index": i, "embedding": list(VECTORS[t])} for i, t in enumerate(input)],
+ )
+ )
+ monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", embedding_router)
+ monkeypatch.setattr(litellm, "skill_search_embedding_model", "text-embedding-3-small")
+ return embedding_router
+
+
+class TestGetSkillsQuery:
+ def test_query_ranks_and_scores_and_truncates(
+ self, accessible_skills: AsyncMock, embedding_router: MagicMock
+ ) -> None:
+ response = _client(LitellmUserRoles.PROXY_ADMIN).get(
+ "/v1/skills",
+ params={"custom_llm_provider": "litellm_proxy", "query": "language translation", "top_k": 2},
+ headers={"Authorization": "Bearer k"},
+ )
+ assert response.status_code == 200
+ body = response.json()["data"]
+ assert [skill["id"] for skill in body] == ["translate-file", "trip-planner"]
+ assert body[0]["search_score"] > body[1]["search_score"]
+ assert embedding_router.aembedding.await_args.kwargs["metadata"]["user_api_key_user_id"] == "u"
+
+ def test_restricted_key_only_ranks_the_skills_it_can_access(
+ self, accessible_skills: AsyncMock, embedding_router: MagicMock
+ ) -> None:
+ accessible_skills.return_value = [SQL_ANALYST]
+ response = _client(LitellmUserRoles.INTERNAL_USER).get(
+ "/v1/skills",
+ params={"custom_llm_provider": "litellm_proxy", "query": "language translation"},
+ headers={"Authorization": "Bearer k"},
+ )
+ assert response.status_code == 200
+ assert [skill["id"] for skill in response.json()["data"]] == ["warehouse-sql-analyst"]
+
+ def test_no_accessible_skills_is_a_no_match_empty_result(
+ self, accessible_skills: AsyncMock, embedding_router: MagicMock
+ ) -> None:
+ accessible_skills.return_value = []
+ response = _client(LitellmUserRoles.INTERNAL_USER).get(
+ "/v1/skills",
+ params={"custom_llm_provider": "litellm_proxy", "query": "anything"},
+ headers={"Authorization": "Bearer k"},
+ )
+ assert response.status_code == 200
+ assert response.json()["data"] == []
+ embedding_router.aembedding.assert_not_awaited()
+
+ def test_query_is_unsupported_for_the_anthropic_passthrough_provider(
+ self, accessible_skills: AsyncMock, embedding_router: MagicMock
+ ) -> None:
+ response = _client(LitellmUserRoles.PROXY_ADMIN).get(
+ "/v1/skills", params={"query": "anything"}, headers={"Authorization": "Bearer k"}
+ )
+ assert response.status_code == 400
+ assert response.json()["detail"]["error"] == "skill_search_unsupported_provider"
+ accessible_skills.assert_not_awaited()
+
+ def test_missing_embedding_model_is_a_400(
+ self, accessible_skills: AsyncMock, embedding_router: MagicMock, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ monkeypatch.setattr(litellm, "skill_search_embedding_model", None)
+ response = _client(LitellmUserRoles.PROXY_ADMIN).get(
+ "/v1/skills",
+ params={"custom_llm_provider": "litellm_proxy", "query": "anything"},
+ headers={"Authorization": "Bearer k"},
+ )
+ assert response.status_code == 400
+ assert response.json()["detail"]["error"] == "skill_search_not_configured"
+
+ def test_embedding_provider_failure_is_a_503(
+ self, accessible_skills: AsyncMock, embedding_router: MagicMock
+ ) -> None:
+ embedding_router.aembedding = AsyncMock(side_effect=APIConnectionError(request=MagicMock()))
+ response = _client(LitellmUserRoles.PROXY_ADMIN).get(
+ "/v1/skills",
+ params={"custom_llm_provider": "litellm_proxy", "query": "anything"},
+ headers={"Authorization": "Bearer k"},
+ )
+ assert response.status_code == 503
+ assert response.json()["detail"]["error"] == "skill_search_unavailable"
+
+ def test_a_key_over_its_rate_limit_gets_a_429_without_embedding(
+ self, accessible_skills: AsyncMock, embedding_router: MagicMock, key_limits: MagicMock
+ ) -> None:
+ key_limits.pre_call_hook = AsyncMock(side_effect=ProxyRateLimitError(detail="rpm exceeded"))
+ response = _client(LitellmUserRoles.PROXY_ADMIN).get(
+ "/v1/skills",
+ params={"custom_llm_provider": "litellm_proxy", "query": "language translation"},
+ headers={"Authorization": "Bearer k"},
+ )
+ assert response.status_code == 429
+ embedding_router.aembedding.assert_not_awaited()
+
+ def test_top_k_is_validated(self, accessible_skills: AsyncMock, embedding_router: MagicMock) -> None:
+ response = _client(LitellmUserRoles.PROXY_ADMIN).get(
+ "/v1/skills",
+ params={"custom_llm_provider": "litellm_proxy", "query": "anything", "top_k": 0},
+ headers={"Authorization": "Bearer k"},
+ )
+ assert response.status_code == 422
diff --git a/tests/test_litellm/skills/test_skills_main.py b/tests/test_litellm/skills/test_skills_main.py
new file mode 100644
index 00000000000..e1c66c8d9ea
--- /dev/null
+++ b/tests/test_litellm/skills/test_skills_main.py
@@ -0,0 +1,57 @@
+from unittest.mock import MagicMock
+
+import litellm.skills.main as skills_main
+from litellm.types.utils import LlmProviders
+
+
+def test_create_skill_forwards_description_and_instructions_from_top_level_kwargs(
+ monkeypatch,
+) -> None:
+ """The REST /v1/skills form endpoint passes description/instructions as top-level
+ kwargs (not extra_body). Regression for a bug where the litellm_proxy dispatch
+ branch of create_skill() dropped both, so every LiteLLM-hosted skill was created
+ with description=None and instructions=None regardless of what the caller sent."""
+ handler = MagicMock()
+ monkeypatch.setattr(skills_main, "_get_litellm_skills_handler", lambda: handler)
+
+ skills_main.create_skill(
+ display_title="Document Translator",
+ description="Converts files from one language into another",
+ instructions="Take an uploaded document and produce it in the target language",
+ custom_llm_provider=LlmProviders.LITELLM_PROXY.value,
+ )
+
+ assert handler.create_skill_handler.call_args.kwargs["description"] == (
+ "Converts files from one language into another"
+ )
+ assert handler.create_skill_handler.call_args.kwargs["instructions"] == (
+ "Take an uploaded document and produce it in the target language"
+ )
+
+
+def test_create_skill_forwards_description_and_instructions_from_extra_body(monkeypatch) -> None:
+ """The SDK convention (see tests/proxy_unit_tests/test_skills_db.py) nests them under
+ extra_body instead of passing them as top-level kwargs; both paths must reach the DB."""
+ handler = MagicMock()
+ monkeypatch.setattr(skills_main, "_get_litellm_skills_handler", lambda: handler)
+
+ skills_main.create_skill(
+ display_title="Warehouse SQL Analyst",
+ extra_body={"description": "Runs SQL against the inventory database", "instructions": "Summarize results"},
+ custom_llm_provider=LlmProviders.LITELLM_PROXY.value,
+ )
+
+ assert handler.create_skill_handler.call_args.kwargs["description"] == (
+ "Runs SQL against the inventory database"
+ )
+ assert handler.create_skill_handler.call_args.kwargs["instructions"] == "Summarize results"
+
+
+def test_create_skill_without_description_or_instructions_passes_none(monkeypatch) -> None:
+ handler = MagicMock()
+ monkeypatch.setattr(skills_main, "_get_litellm_skills_handler", lambda: handler)
+
+ skills_main.create_skill(display_title="Bare Skill", custom_llm_provider=LlmProviders.LITELLM_PROXY.value)
+
+ assert handler.create_skill_handler.call_args.kwargs["description"] is None
+ assert handler.create_skill_handler.call_args.kwargs["instructions"] is None
diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts
index 842a3da4122..12e7eb801e6 100644
--- a/ui/litellm-dashboard/src/lib/http/schema.d.ts
+++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts
@@ -19872,6 +19872,12 @@ export interface paths {
* curl "http://localhost:4000/v1/skills?beta=true&limit=10" -H "Authorization: Bearer your-key" -H "x-litellm-model: claude-account-1"
* ```
*
+ * Pass `?custom_llm_provider=litellm_proxy&query=` to rank the LiteLLM-hosted skills you can
+ * access by semantic similarity instead of paging through the whole registry:
+ * ```bash
+ * curl "http://localhost:4000/v1/skills?custom_llm_provider=litellm_proxy&query=summarize+a+pdf&top_k=5" -H "Authorization: Bearer your-key"
+ * ```
+ *
* Returns: ListSkillsResponse with list of skills
*/
get: operations["list_skills_v1_skills_get"];
@@ -36050,12 +36056,16 @@ export interface components {
Skill: {
/** Created At */
created_at: string;
+ /** Description */
+ description?: string | null;
/** Display Title */
display_title?: string | null;
/** Id */
id: string;
/** Latest Version */
latest_version?: string | null;
+ /** Search Score */
+ search_score?: number | null;
/** Source */
source: string;
/**
@@ -64556,6 +64566,10 @@ export interface operations {
after_id?: string | null;
before_id?: string | null;
custom_llm_provider?: string | null;
+ /** @description Describe what you need in natural language to rank the skills you can access by semantic similarity over their title and description. Each result carries a search_score. Only supported for custom_llm_provider=litellm_proxy. Requires litellm_settings.skill_search_embedding_model. */
+ query?: string | null;
+ /** @description With query: the maximum number of ranked skills to return. */
+ top_k?: number;
};
header?: never;
path?: never;
From e11a8c59ff0497b56e0023e642647be1b338fde8 Mon Sep 17 00:00:00 2001
From: "devin-ai-integration[bot]"
<158243242+devin-ai-integration[bot]@users.noreply.github.com>
Date: Mon, 7 Sep 2026 19:34:43 +0000
Subject: [PATCH 113/123] fix(ui): show inherited MCP servers on the internal
user editor and flag access groups with no members (#40036)
* fix(ui): show inherited MCP servers on the internal-user editor and flag access groups with no members
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* fix(ui): consult the unfiltered access group registry before calling a group empty
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---------
Co-authored-by: yassin
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
.../users/_components/user_edit_view.test.tsx | 47 ++++++++++++++++++-
.../users/_components/user_edit_view.tsx | 2 +
.../MCPToolPermissions.test.tsx | 40 ++++++++++++++++
.../MCPToolPermissions.tsx | 22 ++++++++-
.../effectiveMcpServers.test.ts | 24 ++++++++++
.../effectiveMcpServers.ts | 13 +++++
6 files changed, 146 insertions(+), 2 deletions(-)
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/user_edit_view.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/user_edit_view.test.tsx
index 8fb94ce477e..2571eb344f5 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/user_edit_view.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/user_edit_view.test.tsx
@@ -1,8 +1,11 @@
import { cleanup, fireEvent, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import { renderWithProviders } from "../../../../../tests/test-utils";
+import { renderWithProviders, testQueryClient } from "../../../../../tests/test-utils";
import { UserEditView } from "./user_edit_view";
+import * as networking from "@/components/networking";
+
+vi.mock("@/components/networking");
vi.mock("@/components/key_team_helpers/fetch_available_models_team_key", () => ({
getModelDisplayName: vi.fn((model: string) => model),
@@ -59,6 +62,10 @@ describe("UserEditView", () => {
beforeEach(() => {
vi.clearAllMocks();
+ testQueryClient.clear();
+ vi.mocked(networking.fetchMCPServers).mockResolvedValue([]);
+ vi.mocked(networking.fetchMCPAccessGroups).mockResolvedValue([]);
+ vi.mocked(networking.fetchMCPToolsets).mockResolvedValue([]);
});
afterEach(() => {
@@ -579,6 +586,44 @@ describe("UserEditView", () => {
expect(budgetInput.closest("form")).not.toHaveAttribute("novalidate");
});
+ it("shows the tool matrix for servers the user reaches only through an access group or toolset", async () => {
+ vi.mocked(networking.fetchMCPServers).mockResolvedValue([
+ { server_id: "srv-group", server_name: "Group Server", alias: "Group Server", mcp_access_groups: ["group-a"] },
+ { server_id: "srv-toolset", server_name: "Toolset Server", alias: "Toolset Server" },
+ ]);
+ vi.mocked(networking.fetchMCPAccessGroups).mockResolvedValue(["group-a"]);
+ vi.mocked(networking.fetchMCPToolsets).mockResolvedValue([
+ {
+ toolset_id: "toolset-a",
+ toolset_name: "Toolset A",
+ tools: [{ server_id: "srv-toolset", tool_name: "list_issues" }],
+ } as never,
+ ]);
+ vi.mocked(networking.listMCPTools).mockResolvedValue({
+ tools: [{ name: "list_issues", description: "List issues" }],
+ error: false,
+ });
+
+ renderWithProviders(
+ ,
+ );
+
+ expect(await screen.findByText("Via access group: group-a")).toBeInTheDocument();
+ expect(await screen.findByText("Via toolset: Toolset A")).toBeInTheDocument();
+ expect(networking.listMCPTools).toHaveBeenCalledWith("test-token", "srv-group");
+ expect(networking.listMCPTools).toHaveBeenCalledWith("test-token", "srv-toolset");
+ });
+
it("should send objects for the mcp keys seeded from objectPermission", async () => {
const payload = await submittedPayload({
objectPermission: {
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/user_edit_view.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/user_edit_view.tsx
index 96771dc6dc4..b7a3486c78e 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/user_edit_view.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/user_edit_view.tsx
@@ -336,6 +336,8 @@ export function UserEditView({
form.setValue("mcp_tool_permissions", toolPerms)}
/>
diff --git a/ui/litellm-dashboard/src/components/mcp_server_management/MCPToolPermissions.test.tsx b/ui/litellm-dashboard/src/components/mcp_server_management/MCPToolPermissions.test.tsx
index 69a761b4723..149d231fff6 100644
--- a/ui/litellm-dashboard/src/components/mcp_server_management/MCPToolPermissions.test.tsx
+++ b/ui/litellm-dashboard/src/components/mcp_server_management/MCPToolPermissions.test.tsx
@@ -19,6 +19,7 @@ describe("MCPToolPermissions", () => {
vi.clearAllMocks();
testQueryClient.clear();
vi.mocked(networking.fetchMCPToolsets).mockResolvedValue([]);
+ vi.mocked(networking.fetchMCPAccessGroups).mockResolvedValue([]);
});
it("should update tool permissions when user selects a tool", async () => {
@@ -621,6 +622,45 @@ describe("MCPToolPermissions", () => {
);
expect(await screen.findByText("Unable to load MCP servers")).toBeInTheDocument();
+ expect(screen.queryByText(/has 0 servers/)).not.toBeInTheDocument();
+ });
+
+ it("tells the admin when a loaded access group has no member servers", async () => {
+ vi.mocked(networking.fetchMCPServers).mockResolvedValue([groupServer]);
+ vi.mocked(networking.fetchMCPToolsets).mockResolvedValue([]);
+ vi.mocked(networking.listMCPTools).mockResolvedValue({ tools: groupTools, error: false });
+
+ renderWithProviders(
+ ,
+ );
+
+ expect(await screen.findByText('Access group "ops_readonly" has 0 servers')).toBeInTheDocument();
+ expect(screen.getByText("Group Server")).toBeInTheDocument();
+ expect(screen.queryByText('Access group "production-group" has 0 servers')).not.toBeInTheDocument();
+ });
+
+ it("does not call a group empty when its servers are only hidden from the caller's catalog", async () => {
+ vi.mocked(networking.fetchMCPServers).mockResolvedValue([]);
+ vi.mocked(networking.fetchMCPAccessGroups).mockResolvedValue(["production-group"]);
+
+ renderWithProviders(
+ ,
+ );
+
+ expect(await screen.findByText('Access group "ops_readonly" has 0 servers')).toBeInTheDocument();
+ expect(screen.queryByText('Access group "production-group" has 0 servers')).not.toBeInTheDocument();
});
it("warns when the selected toolsets cannot be resolved to servers", async () => {
diff --git a/ui/litellm-dashboard/src/components/mcp_server_management/MCPToolPermissions.tsx b/ui/litellm-dashboard/src/components/mcp_server_management/MCPToolPermissions.tsx
index c866e9cc011..e26f1a6f511 100644
--- a/ui/litellm-dashboard/src/components/mcp_server_management/MCPToolPermissions.tsx
+++ b/ui/litellm-dashboard/src/components/mcp_server_management/MCPToolPermissions.tsx
@@ -4,6 +4,7 @@ import { MCPTool } from "../mcp_tools/types";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import { useMCPServers } from "../../app/(dashboard)/hooks/mcpServers/useMCPServers";
+import { useMCPAccessGroups } from "../../app/(dashboard)/hooks/mcpServers/useMCPAccessGroups";
import { useMCPToolsets } from "../../app/(dashboard)/hooks/mcpServers/useMCPToolsets";
import McpCrudPermissionPanel from "../mcp_tools/McpCrudPermissionPanel";
import { classifyToolOp } from "../../utils/mcpToolCrudClassification";
@@ -12,6 +13,7 @@ import {
EffectiveMcpServer,
McpGrantSource,
applyToolPermissionWrite,
+ emptyMcpAccessGroups,
mcpAllowedToolsFor,
resolveEffectiveMcpServers,
} from "./effectiveMcpServers";
@@ -55,7 +57,13 @@ const MCPToolPermissions: React.FC = ({
onChange,
disabled = false,
}) => {
- const { data: allServers = [], isError: serversFailed, isLoading: serversLoading } = useMCPServers();
+ const {
+ data: allServers = [],
+ isError: serversFailed,
+ isLoading: serversLoading,
+ isSuccess: serversLoaded,
+ } = useMCPServers();
+ const { data: populatedAccessGroups = [], isSuccess: accessGroupsLoaded } = useMCPAccessGroups();
const { data: toolsets = [], isError: toolsetsFailed, isLoading: toolsetsLoading } = useMCPToolsets();
const [serverTools, setServerTools] = useState>({});
const [loadingTools, setLoadingTools] = useState>({});
@@ -181,6 +189,18 @@ const MCPToolPermissions: React.FC = ({
)}
+ {serversLoaded &&
+ accessGroupsLoaded &&
+ emptyMcpAccessGroups(allServers, populatedAccessGroups, selectedAccessGroups).map((group) => (
+
+
Access group "{group}" has 0 servers
+
+ No MCP server lists this group, so it grants nothing. A server defined in config.yaml joins a group
+ through its access_groups key; mcp_access_groups is ignored there
+
+
+ ))}
+
{toolsetsFailed && selectedToolsets.length > 0 && (
Unable to load toolsets
diff --git a/ui/litellm-dashboard/src/components/mcp_server_management/effectiveMcpServers.test.ts b/ui/litellm-dashboard/src/components/mcp_server_management/effectiveMcpServers.test.ts
index b2ac5337245..487c6f9f55e 100644
--- a/ui/litellm-dashboard/src/components/mcp_server_management/effectiveMcpServers.test.ts
+++ b/ui/litellm-dashboard/src/components/mcp_server_management/effectiveMcpServers.test.ts
@@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest";
import { MCPServer, MCPToolset } from "../mcp_tools/types";
import {
applyToolPermissionWrite,
+ emptyMcpAccessGroups,
mcpAllowedToolsFor,
mcpServersForIdentifier,
mcpToolPermissionKeyFor,
@@ -101,6 +102,29 @@ describe("mcpToolPermissionKeyFor", () => {
});
});
+describe("emptyMcpAccessGroups", () => {
+ const grouped = server({ server_id: "srv-group", server_name: "Grouped", mcp_access_groups: ["prod"] });
+ const objectGrouped = {
+ ...grouped,
+ server_id: "srv-obj",
+ mcp_access_groups: [{ name: "legacy" }],
+ } as unknown as MCPServer;
+
+ it("names only the selected groups no loaded server belongs to", () => {
+ expect(emptyMcpAccessGroups([grouped, objectGrouped], [], ["prod", "legacy", "ops_readonly"])).toEqual([
+ "ops_readonly",
+ ]);
+ });
+
+ it("names every selected group when no server is loaded and the registry is empty", () => {
+ expect(emptyMcpAccessGroups([], [], ["prod"])).toEqual(["prod"]);
+ });
+
+ it("trusts the group registry when the caller's catalog hides the member servers", () => {
+ expect(emptyMcpAccessGroups([], ["prod"], ["prod", "ops_readonly"])).toEqual(["ops_readonly"]);
+ });
+});
+
describe("resolveEffectiveMcpServers", () => {
const direct = server({ server_id: "srv-direct", server_name: "Direct" });
const grouped = server({ server_id: "srv-group", server_name: "Grouped", mcp_access_groups: ["prod"] });
diff --git a/ui/litellm-dashboard/src/components/mcp_server_management/effectiveMcpServers.ts b/ui/litellm-dashboard/src/components/mcp_server_management/effectiveMcpServers.ts
index 3320dc135c9..b3ba24f3c59 100644
--- a/ui/litellm-dashboard/src/components/mcp_server_management/effectiveMcpServers.ts
+++ b/ui/litellm-dashboard/src/components/mcp_server_management/effectiveMcpServers.ts
@@ -54,6 +54,19 @@ const accessGroupNamesOf = (server: MCPServer): readonly string[] =>
return [typeof parsed.data === "string" ? parsed.data : parsed.data.name];
});
+// The server catalog can be trimmed to the caller's grants, so the unfiltered group registry
+// (GET /v1/mcp/access_groups) has to agree before a group is called empty.
+export const emptyMcpAccessGroups = (
+ allServers: readonly MCPServer[],
+ populatedAccessGroups: readonly string[],
+ selectedAccessGroups: readonly string[],
+): readonly string[] =>
+ selectedAccessGroups.filter(
+ (group) =>
+ !populatedAccessGroups.includes(group) &&
+ !allServers.some((server) => accessGroupNamesOf(server).includes(group)),
+ );
+
// Which servers an identifier names, with the same precedence the backend's expand_permission_list
// applies: a string that is a registry server id names exactly that server, and only a string that
// is not falls back to server_name/alias, which can name several. Matching all three fields at once
From 6908318c168cf50d750b3535fc3a2e4def0abf8e Mon Sep 17 00:00:00 2001
From: "devin-ai-integration[bot]"
<158243242+devin-ai-integration[bot]@users.noreply.github.com>
Date: Mon, 7 Sep 2026 19:37:44 +0000
Subject: [PATCH 114/123] feat(keys): allow editing soft budget on existing
keys (#39002)
* feat(keys): allow editing soft budget on existing keys
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* refactor(ui): extract KeyBudgetNumberField to keep key_edit_view under max-lines
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* style(ui): format keyEditFormValues with prettier
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* test(keys): cover soft budget validation and update adapter
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* fix(ui): reject non-finite soft budget values instead of clearing
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* test(keys): assert soft budget validation returns None for valid values
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* fix(keys): write soft budget and key row in one transaction
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---------
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: yassin
---
litellm/proxy/_types.py | 1 +
.../key_management_endpoints.py | 144 +++++++++-
.../test_key_management_endpoints.py | 247 ++++++++++++++++++
.../templates/KeyEditViewControls.tsx | 28 ++
.../KeyInfoView.handleKeyUpdate.test.tsx | 106 ++++++++
.../components/templates/keyEditFormValues.ts | 5 +
.../templates/key_edit_view.test.tsx | 1 +
.../components/templates/key_edit_view.tsx | 26 +-
.../components/templates/key_info_view.tsx | 17 ++
ui/litellm-dashboard/src/lib/http/schema.d.ts | 4 +-
10 files changed, 557 insertions(+), 22 deletions(-)
diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py
index c28ac8848ba..d79b753b5ff 100644
--- a/litellm/proxy/_types.py
+++ b/litellm/proxy/_types.py
@@ -1276,6 +1276,7 @@ class UpdateKeyRequest(KeyRequestBase):
# else they will get overwritten
duration: str | None = None
spend: float | None = None
+ soft_budget: float | None = None
metadata: dict | None = None
temp_budget_increase: float | None = None
temp_budget_expiry: datetime | None = None
diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py
index 8e51e250319..f46c4170071 100644
--- a/litellm/proxy/management_endpoints/key_management_endpoints.py
+++ b/litellm/proxy/management_endpoints/key_management_endpoints.py
@@ -196,6 +196,38 @@ class _ModelRowWhere(TypedDict):
model_id: ReadOnly[str]
+class _KeyUpdateResult(TypedDict):
+ token: ReadOnly[str]
+ data: ReadOnly[Mapping[str, object]]
+
+
+class _KeyRowWhere(TypedDict):
+ token: ReadOnly[str]
+
+
+class _BudgetRowWhere(TypedDict):
+ budget_id: ReadOnly[str]
+
+
+class _BudgetRowSoftBudgetUpdate(TypedDict):
+ soft_budget: ReadOnly[float | None]
+ updated_by: ReadOnly[str]
+
+
+class _BudgetRowSoftBudgetCreate(TypedDict):
+ soft_budget: ReadOnly[float]
+ created_by: ReadOnly[str]
+ updated_by: ReadOnly[str]
+
+
+class _KeyUpdateTx(Protocol):
+ @property
+ def litellm_verificationtoken(self) -> "TableActions[prisma_models.LiteLLM_VerificationToken]": ...
+
+ @property
+ def litellm_budgettable(self) -> "TableActions[prisma_models.LiteLLM_BudgetTable]": ...
+
+
class _ConfigTableActions(Protocol):
"""Config table surface this module needs; the shared repository seam exposes no ``update``."""
@@ -1812,11 +1844,7 @@ async def generate_key_fn(
status_code=400,
detail={"error": f"max_budget must be a non-negative finite number. Received: {data.max_budget}"},
)
- if data.soft_budget is not None and (not math.isfinite(data.soft_budget) or data.soft_budget < 0):
- raise HTTPException(
- status_code=400,
- detail={"error": f"soft_budget must be a non-negative finite number. Received: {data.soft_budget}"},
- )
+ _validate_soft_budget_value(data.soft_budget)
custom_key_generate_hook: Final[Callable[..., Awaitable[Mapping[str, object]]] | None] = (
_custom_key_generate_hook(proxy_server)
@@ -2121,6 +2149,88 @@ def prepare_metadata_fields(data: BaseModel, non_default_values: dict, existing_
return non_default_values
+def _validate_soft_budget_value(soft_budget: float | None) -> None:
+ if soft_budget is not None and (not math.isfinite(soft_budget) or soft_budget < 0):
+ raise HTTPException(
+ status_code=400,
+ detail={"error": f"soft_budget must be a non-negative finite number. Received: {soft_budget}"},
+ )
+
+
+async def _update_key_soft_budget(
+ db: _KeyUpdateTx,
+ existing_key_row: LiteLLM_VerificationToken,
+ soft_budget: float | None,
+ changed_by: str,
+) -> str | None:
+ existing_budget_id: Final = existing_key_row.budget_id
+ if existing_budget_id is not None:
+ budget_update: Final[_BudgetRowSoftBudgetUpdate] = {"soft_budget": soft_budget, "updated_by": changed_by}
+ budget_where: Final[_BudgetRowWhere] = {"budget_id": existing_budget_id}
+ await db.litellm_budgettable.update(where=budget_where, data=budget_update)
+ return existing_budget_id
+ if soft_budget is None:
+ return None
+ budget_create: Final[_BudgetRowSoftBudgetCreate] = {
+ "soft_budget": soft_budget,
+ "created_by": changed_by,
+ "updated_by": changed_by,
+ }
+ created_budget: Final = await db.litellm_budgettable.create(data=budget_create)
+ return created_budget.budget_id
+
+
+async def _apply_soft_budget_update(
+ data: UpdateKeyRequest,
+ non_default_values: Mapping[str, object],
+ db: _KeyUpdateTx,
+ existing_key_row: LiteLLM_VerificationToken,
+ changed_by: str,
+) -> Mapping[str, object]:
+ remaining: Final = MappingProxyType({k: v for k, v in non_default_values.items() if k != "soft_budget"})
+ updated_budget_id: Final = await _update_key_soft_budget(
+ db=db,
+ existing_key_row=existing_key_row,
+ soft_budget=data.soft_budget,
+ changed_by=changed_by,
+ )
+ if updated_budget_id is not None and existing_key_row.budget_id is None:
+ return MappingProxyType({**remaining, "budget_id": updated_budget_id})
+ return remaining
+
+
+async def _update_key_row_with_soft_budget(
+ prisma_client: PrismaClient,
+ key: str,
+ data: UpdateKeyRequest,
+ non_default_values: Mapping[str, object],
+ existing_key_row: LiteLLM_VerificationToken,
+ changed_by: str,
+) -> _KeyUpdateResult:
+ hashed_token: Final = _hash_token_if_needed(key)
+ key_where: Final[_KeyRowWhere] = {"token": hashed_token}
+ tx: _KeyUpdateTx
+ async with prisma_client.tx() as tx:
+ update_values: Final = await _apply_soft_budget_update(
+ data=data,
+ non_default_values=non_default_values,
+ db=tx,
+ existing_key_row=existing_key_row,
+ changed_by=changed_by,
+ )
+ updated_row: Final = await tx.litellm_verificationtoken.update(
+ where=key_where,
+ data=with_settings_updated_at(
+ prisma_client.jsonify_object(MappingProxyType({**update_values, "token": hashed_token}))
+ ),
+ )
+ updated_data: Final[Mapping[str, object]] = (
+ updated_row.model_dump() if updated_row is not None else MappingProxyType({})
+ )
+ result: Final[_KeyUpdateResult] = {"token": hashed_token, "data": updated_data}
+ return result
+
+
async def prepare_key_update_data(
data: UpdateKeyRequest | RegenerateKeyRequest,
existing_key_row: LiteLLM_VerificationToken,
@@ -2659,6 +2769,7 @@ async def _validate_update_key_data(
(data.max_budget is not None and data.max_budget != existing_key_row.max_budget)
or data.spend is not None
or "budget_limits" in data.model_fields_set
+ or "soft_budget" in data.model_fields_set
)
_existing_metadata: Final = getattr(existing_key_row, "metadata", None)
@@ -2862,7 +2973,7 @@ async def update_key_fn(
- model_max_budget: Optional[Dict[str, BudgetConfig]] - Model-specific budgets {"gpt-4": {"budget_limit": 0.0005, "time_period": "30d"}}
- budget_fallbacks: Optional[Dict[str, List[str]]] - Per-model fallback chain tried in order when that model's own `model_max_budget` is exceeded, e.g. {"gpt-4o": ["gpt-4o-mini"]}.
- budget_duration: Optional[str] - Budget reset period ("30d", "1h", etc.)
- - soft_budget: Optional[float] - [TODO] Soft budget limit (warning vs. hard stop). Will trigger a slack alert when this soft budget is reached.
+ - soft_budget: Optional[float] - Soft budget limit (warning vs. hard stop). Will trigger a slack alert when this soft budget is reached. Set to null to remove the soft budget.
- max_parallel_requests: Optional[int] - Rate limit for parallel requests
- metadata: Optional[dict] - Metadata for key. Example {"team": "core-infra", "app": "app2"}
- tpm_limit: Optional[int] - Tokens per minute limit
@@ -2918,6 +3029,7 @@ async def update_key_fn(
"""
from litellm.proxy import proxy_server
from litellm.proxy.proxy_server import (
+ litellm_proxy_admin_name,
llm_router,
premium_user,
prisma_client,
@@ -2933,6 +3045,8 @@ async def update_key_fn(
detail={"error": f"max_budget must be a non-negative finite number. Received: {data.max_budget}"},
)
+ _validate_soft_budget_value(data.soft_budget)
+
# get the row from db
existing_key_row: Final = await _get_and_validate_existing_key(
token=data.key,
@@ -2989,10 +3103,22 @@ async def update_key_fn(
existing_key_alias=existing_key_row.key_alias,
)
- _data: Final = {**non_default_values, "token": key}
if prisma_client is None:
raise Exception("Not connected to DB!")
- response: Final = await prisma_client.update_data(token=key, data=_data)
+
+ changed_by: Final = user_api_key_dict.user_id or litellm_proxy_admin_name
+ response: Final = (
+ await _update_key_row_with_soft_budget(
+ prisma_client=prisma_client,
+ key=key,
+ data=data,
+ non_default_values=non_default_values,
+ existing_key_row=existing_key_row,
+ changed_by=changed_by,
+ )
+ if "soft_budget" in data.model_fields_set
+ else await prisma_client.update_data(token=key, data=MappingProxyType({**non_default_values, "token": key}))
+ )
# Delete - key from cache, since it's been updated!
# key updated - a new model could have been added to this key. it should not block requests after this is done
@@ -6432,7 +6558,7 @@ async def _list_key_helper(
{"token": "desc"}, # fallback sort
]
),
- include={"object_permission": True},
+ include={"object_permission": True, "litellm_budget_table": True},
)
verbose_proxy_logger.debug("Fetched %s keys", len(keys))
diff --git a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py
index 8766b1a1868..a873a367eab 100644
--- a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py
+++ b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py
@@ -17692,6 +17692,253 @@ async def test_check_project_key_limits_still_rejects_real_model_outside_project
assert "Model 'gpt-5.4-mini' not in project's allowed models" in exc_info.value.detail["error"]
+@pytest.mark.asyncio
+async def test_update_key_soft_budget_updates_existing_budget_row():
+ from litellm.proxy.management_endpoints.key_management_endpoints import (
+ _update_key_soft_budget,
+ )
+
+ existing_key = LiteLLM_VerificationToken(token="test-token", budget_id="budget-123")
+ mock_db = MagicMock()
+ mock_db.litellm_budgettable.update = AsyncMock()
+ mock_db.litellm_budgettable.create = AsyncMock()
+
+ result = await _update_key_soft_budget(
+ db=mock_db,
+ existing_key_row=existing_key,
+ soft_budget=25.0,
+ changed_by="user-1",
+ )
+
+ assert result == "budget-123"
+ mock_db.litellm_budgettable.update.assert_awaited_once_with(
+ where={"budget_id": "budget-123"},
+ data={"soft_budget": 25.0, "updated_by": "user-1"},
+ )
+
+
+@pytest.mark.asyncio
+async def test_update_key_soft_budget_clears_existing_budget_row():
+ from litellm.proxy.management_endpoints.key_management_endpoints import (
+ _update_key_soft_budget,
+ )
+
+ existing_key = LiteLLM_VerificationToken(token="test-token", budget_id="budget-123")
+ mock_db = MagicMock()
+ mock_db.litellm_budgettable.update = AsyncMock()
+ mock_db.litellm_budgettable.create = AsyncMock()
+
+ result = await _update_key_soft_budget(
+ db=mock_db,
+ existing_key_row=existing_key,
+ soft_budget=None,
+ changed_by="user-1",
+ )
+
+ assert result == "budget-123"
+ mock_db.litellm_budgettable.update.assert_awaited_once_with(
+ where={"budget_id": "budget-123"},
+ data={"soft_budget": None, "updated_by": "user-1"},
+ )
+
+
+@pytest.mark.asyncio
+async def test_update_key_soft_budget_creates_budget_row_when_key_has_none():
+ from litellm.proxy.management_endpoints.key_management_endpoints import (
+ _update_key_soft_budget,
+ )
+
+ existing_key = LiteLLM_VerificationToken(token="test-token", budget_id=None)
+ created_row = MagicMock()
+ created_row.budget_id = "budget-new"
+ mock_db = MagicMock()
+ mock_db.litellm_budgettable.create = AsyncMock(return_value=created_row)
+ mock_db.litellm_budgettable.update = AsyncMock()
+
+ result = await _update_key_soft_budget(
+ db=mock_db,
+ existing_key_row=existing_key,
+ soft_budget=10.5,
+ changed_by="user-1",
+ )
+
+ assert result == "budget-new"
+ mock_db.litellm_budgettable.create.assert_awaited_once_with(
+ data={"soft_budget": 10.5, "created_by": "user-1", "updated_by": "user-1"}
+ )
+
+
+@pytest.mark.asyncio
+async def test_update_key_soft_budget_noop_when_clearing_without_budget_row():
+ from litellm.proxy.management_endpoints.key_management_endpoints import (
+ _update_key_soft_budget,
+ )
+
+ existing_key = LiteLLM_VerificationToken(token="test-token", budget_id=None)
+ mock_db = MagicMock()
+ mock_db.litellm_budgettable.create = AsyncMock()
+ mock_db.litellm_budgettable.update = AsyncMock()
+
+ result = await _update_key_soft_budget(
+ db=mock_db,
+ existing_key_row=existing_key,
+ soft_budget=None,
+ changed_by="user-1",
+ )
+
+ assert result is None
+ mock_db.litellm_budgettable.create.assert_not_awaited()
+ mock_db.litellm_budgettable.update.assert_not_awaited()
+
+
+def test_update_key_request_accepts_soft_budget():
+ request = UpdateKeyRequest(key="sk-test", soft_budget=42.0)
+ assert request.soft_budget == 42.0
+ assert "soft_budget" in request.model_fields_set
+
+
+@pytest.mark.parametrize("valid_value", [None, 0.0, 25.0])
+def test_validate_soft_budget_value_accepts_valid_values(valid_value):
+ from litellm.proxy.management_endpoints.key_management_endpoints import (
+ _validate_soft_budget_value,
+ )
+
+ assert _validate_soft_budget_value(valid_value) is None
+
+
+@pytest.mark.parametrize("invalid_value", [-5.0, float("nan"), float("inf")])
+def test_validate_soft_budget_value_rejects_invalid_values(invalid_value):
+ from litellm.proxy.management_endpoints.key_management_endpoints import (
+ _validate_soft_budget_value,
+ )
+
+ with pytest.raises(HTTPException) as exc_info:
+ _validate_soft_budget_value(invalid_value)
+
+ assert exc_info.value.status_code == 400
+ assert "soft_budget must be a non-negative finite number" in str(exc_info.value.detail)
+
+
+@pytest.mark.asyncio
+async def test_apply_soft_budget_update_adds_budget_id_for_new_budget_row():
+ from litellm.proxy.management_endpoints.key_management_endpoints import (
+ _apply_soft_budget_update,
+ )
+
+ existing_key = LiteLLM_VerificationToken(token="test-token", budget_id=None)
+ created_row = MagicMock()
+ created_row.budget_id = "budget-created-456"
+ mock_db = MagicMock()
+ mock_db.litellm_budgettable.create = AsyncMock(return_value=created_row)
+ mock_db.litellm_budgettable.update = AsyncMock()
+
+ result = await _apply_soft_budget_update(
+ data=UpdateKeyRequest(key="sk-test", soft_budget=25.0),
+ non_default_values={"soft_budget": 25.0},
+ db=mock_db,
+ existing_key_row=existing_key,
+ changed_by="user-1",
+ )
+
+ assert dict(result) == {"budget_id": "budget-created-456"}
+
+
+@pytest.mark.asyncio
+async def test_apply_soft_budget_update_keeps_existing_budget_id_out_of_token_update():
+ from litellm.proxy.management_endpoints.key_management_endpoints import (
+ _apply_soft_budget_update,
+ )
+
+ existing_key = LiteLLM_VerificationToken(token="test-token", budget_id="budget-123")
+ mock_db = MagicMock()
+ mock_db.litellm_budgettable.update = AsyncMock()
+ mock_db.litellm_budgettable.create = AsyncMock()
+
+ result = await _apply_soft_budget_update(
+ data=UpdateKeyRequest(key="sk-test", soft_budget=40.0),
+ non_default_values={"soft_budget": 40.0, "max_budget": 100.0},
+ db=mock_db,
+ existing_key_row=existing_key,
+ changed_by="user-1",
+ )
+
+ assert dict(result) == {"max_budget": 100.0}
+ mock_db.litellm_budgettable.update.assert_awaited_once_with(
+ where={"budget_id": "budget-123"},
+ data={"soft_budget": 40.0, "updated_by": "user-1"},
+ )
+
+
+@pytest.mark.asyncio
+async def test_update_key_row_with_soft_budget_updates_budget_and_key_in_transaction():
+ from litellm.proxy.management_endpoints.key_management_endpoints import (
+ _update_key_row_with_soft_budget,
+ )
+
+ existing_key = LiteLLM_VerificationToken(token="test-token", budget_id=None)
+ created_row = MagicMock(budget_id="budget-new")
+ updated_row = MagicMock()
+ updated_row.model_dump.return_value = {"token": "hashed", "budget_id": "budget-new"}
+ tx = MagicMock()
+ tx.litellm_budgettable.create = AsyncMock(return_value=created_row)
+ tx.litellm_verificationtoken.update = AsyncMock(return_value=updated_row)
+ tx_context = MagicMock()
+ tx_context.__aenter__ = AsyncMock(return_value=tx)
+ tx_context.__aexit__ = AsyncMock(return_value=None)
+ prisma_client = MagicMock()
+ prisma_client.tx.return_value = tx_context
+ prisma_client.jsonify_object = lambda data: dict(data)
+
+ result = await _update_key_row_with_soft_budget(
+ prisma_client=prisma_client,
+ key="sk-test",
+ data=UpdateKeyRequest(key="sk-test", soft_budget=25.0),
+ non_default_values={"soft_budget": 25.0},
+ existing_key_row=existing_key,
+ changed_by="user-1",
+ )
+
+ assert set(result) == {"token", "data"}
+ assert result["data"] == {"token": "hashed", "budget_id": "budget-new"}
+ tx.litellm_verificationtoken.update.assert_awaited_once()
+ update_call = tx.litellm_verificationtoken.update.await_args
+ assert update_call.kwargs["where"] == {"token": result["token"]}
+ assert update_call.kwargs["data"]["budget_id"] == "budget-new"
+ assert "soft_budget" not in update_call.kwargs["data"]
+
+
+@pytest.mark.asyncio
+async def test_update_key_row_with_soft_budget_propagates_transaction_error():
+ from litellm.proxy.management_endpoints.key_management_endpoints import (
+ _update_key_row_with_soft_budget,
+ )
+
+ existing_key = LiteLLM_VerificationToken(token="test-token", budget_id=None)
+ created_row = MagicMock(budget_id="budget-new")
+ tx = MagicMock()
+ tx.litellm_budgettable.create = AsyncMock(return_value=created_row)
+ tx.litellm_verificationtoken.update = AsyncMock(side_effect=RuntimeError("update failed"))
+ tx_context = MagicMock()
+ tx_context.__aenter__ = AsyncMock(return_value=tx)
+ tx_context.__aexit__ = AsyncMock(return_value=None)
+ prisma_client = MagicMock()
+ prisma_client.tx.return_value = tx_context
+ prisma_client.jsonify_object = lambda data: dict(data)
+
+ with pytest.raises(RuntimeError, match="update failed"):
+ await _update_key_row_with_soft_budget(
+ prisma_client=prisma_client,
+ key="sk-test",
+ data=UpdateKeyRequest(key="sk-test", soft_budget=25.0),
+ non_default_values={"soft_budget": 25.0},
+ existing_key_row=existing_key,
+ changed_by="user-1",
+ )
+
+ tx_context.__aexit__.assert_awaited_once()
+ assert tx_context.__aexit__.await_args.args[0] is RuntimeError
+
+
def test_generate_key_request_blank_team_id_is_personal():
"""The UI Team-field clear submits team_id=""; it must count as no team (LIT-3925)."""
from litellm.proxy._types import RegenerateKeyRequest
diff --git a/ui/litellm-dashboard/src/components/templates/KeyEditViewControls.tsx b/ui/litellm-dashboard/src/components/templates/KeyEditViewControls.tsx
index 1ab7b9be52c..2bbbc2bd48b 100644
--- a/ui/litellm-dashboard/src/components/templates/KeyEditViewControls.tsx
+++ b/ui/litellm-dashboard/src/components/templates/KeyEditViewControls.tsx
@@ -1,7 +1,11 @@
import React from "react";
+import { Control } from "react-hook-form";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { CircleHelp } from "lucide-react";
+import { FormField } from "@/components/shared/form/FormField";
+import NumericalInput from "../shared/numerical_input";
+import { KeyEditFormValues } from "./keyEditFormValues";
export const labelWithHint = (label: React.ReactNode, hint: string): React.ReactNode => (
<>
@@ -48,3 +52,27 @@ export const KeyTypeSelect = ({
);
+
+export const KeyBudgetNumberField = ({
+ control,
+ name,
+ label,
+ placeholder,
+}: {
+ control: Control;
+ name: "max_budget" | "soft_budget";
+ label: string;
+ placeholder: string;
+}) => (
+
+ {({ ref: _ref, ...field }) => (
+
+ )}
+
+);
diff --git a/ui/litellm-dashboard/src/components/templates/KeyInfoView.handleKeyUpdate.test.tsx b/ui/litellm-dashboard/src/components/templates/KeyInfoView.handleKeyUpdate.test.tsx
index 705679fe2e6..8a42ecff50c 100644
--- a/ui/litellm-dashboard/src/components/templates/KeyInfoView.handleKeyUpdate.test.tsx
+++ b/ui/litellm-dashboard/src/components/templates/KeyInfoView.handleKeyUpdate.test.tsx
@@ -1,5 +1,10 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
+import { toast } from "@/lib/toast";
+
+vi.mock("@/lib/toast", () => ({
+ toast: { error: vi.fn(), success: vi.fn() },
+}));
// ---- Hoisted shared mocks (safe to use inside vi.mock factories) ----
const { keyUpdateCallMock, keyDeleteCallMock, mockUseAuthorized } = vi.hoisted(() => {
@@ -483,3 +488,104 @@ describe("KeyInfoView handleKeyUpdate empty strings", () => {
});
});
});
+
+describe("KeyInfoView handleKeyUpdate soft_budget", () => {
+ const premiumAdminAuth = {
+ accessToken: "access_abc",
+ userId: "user_1",
+ userRole: "Admin",
+ premiumUser: true,
+ token: "token_123",
+ userEmail: "test@example.com",
+ disabledPersonalKeyCreation: false,
+ showSSOBanner: false,
+ };
+
+ const renderWithSoftBudget = (softBudget: number | null) => {
+ mockUseAuthorized.mockReturnValue(premiumAdminAuth);
+
+ return render(
+ {}}
+ keyData={
+ { ...baseKeyData, litellm_budget_table: softBudget === null ? null : { soft_budget: softBudget } } as any
+ }
+ onKeyDataUpdate={() => {}}
+ teams={[]}
+ />,
+ );
+ };
+
+ it("should send a changed soft_budget as a number", async () => {
+ renderWithSoftBudget(null);
+
+ fireEvent.click(screen.getByText("Settings"));
+ fireEvent.click(screen.getByText("Edit Settings"));
+ (globalThis as any).__TEST_FORM_VALUES = {
+ token: "tok_123",
+ soft_budget: "25",
+ };
+
+ fireEvent.click(screen.getByText("Mock Submit"));
+
+ await waitFor(() => expect(keyUpdateCallMock).toHaveBeenCalled());
+
+ const [, sentPayload] = keyUpdateCallMock.mock.calls[0];
+ expect(sentPayload.soft_budget).toBe(25);
+ });
+
+ it("should omit an unchanged soft_budget so unrelated edits skip the budget gate", async () => {
+ renderWithSoftBudget(25);
+
+ fireEvent.click(screen.getByText("Settings"));
+ fireEvent.click(screen.getByText("Edit Settings"));
+ (globalThis as any).__TEST_FORM_VALUES = {
+ token: "tok_123",
+ soft_budget: 25,
+ key_alias: "renamed",
+ };
+
+ fireEvent.click(screen.getByText("Mock Submit"));
+
+ await waitFor(() => expect(keyUpdateCallMock).toHaveBeenCalled());
+
+ const [, sentPayload] = keyUpdateCallMock.mock.calls[0];
+ expect("soft_budget" in sentPayload).toBe(false);
+ });
+
+ it("should forward a cleared soft_budget as an explicit null the JSON body keeps", async () => {
+ renderWithSoftBudget(25);
+
+ fireEvent.click(screen.getByText("Settings"));
+ fireEvent.click(screen.getByText("Edit Settings"));
+ (globalThis as any).__TEST_FORM_VALUES = {
+ token: "tok_123",
+ soft_budget: "",
+ };
+
+ fireEvent.click(screen.getByText("Mock Submit"));
+
+ await waitFor(() => expect(keyUpdateCallMock).toHaveBeenCalled());
+
+ const [, sentPayload] = keyUpdateCallMock.mock.calls[0];
+ expect(sentPayload.soft_budget).toBeNull();
+ expect(JSON.stringify({ ...sentPayload })).toContain('"soft_budget":null');
+ });
+
+ it("should reject an overflowing soft_budget instead of silently clearing it", async () => {
+ renderWithSoftBudget(25);
+
+ fireEvent.click(screen.getByText("Settings"));
+ fireEvent.click(screen.getByText("Edit Settings"));
+ (globalThis as any).__TEST_FORM_VALUES = {
+ token: "tok_123",
+ soft_budget: "1e309",
+ };
+
+ fireEvent.click(screen.getByText("Mock Submit"));
+
+ await waitFor(() => expect(toast.error).toHaveBeenCalled());
+ expect(keyUpdateCallMock).not.toHaveBeenCalled();
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/templates/keyEditFormValues.ts b/ui/litellm-dashboard/src/components/templates/keyEditFormValues.ts
index ad90610b732..f24fb6e5a86 100644
--- a/ui/litellm-dashboard/src/components/templates/keyEditFormValues.ts
+++ b/ui/litellm-dashboard/src/components/templates/keyEditFormValues.ts
@@ -22,6 +22,7 @@ export interface KeyEditFormValues {
models?: string[];
allowed_routes?: string;
max_budget?: number | string | null;
+ soft_budget?: number | string | null;
budget_duration?: string | null;
tpm_limit?: number | string | null;
tpm_limit_type?: string | null;
@@ -67,6 +68,8 @@ export const toKeyEditFormValues = (keyData: KeyResponse): KeyEditFormValues =>
allowed_routes:
Array.isArray(keyData.allowed_routes) && keyData.allowed_routes.length > 0 ? keyData.allowed_routes.join(", ") : "",
max_budget: keyData.max_budget,
+ soft_budget:
+ (keyData.litellm_budget_table as { soft_budget?: number | null } | null | undefined)?.soft_budget ?? null,
budget_duration: canonicalBudgetDuration(keyData.budget_duration),
tpm_limit: keyData.tpm_limit,
tpm_limit_type: (keyData as { tpm_limit_type?: string | null }).tpm_limit_type ?? null,
@@ -117,6 +120,7 @@ export const keyEditFormSchema = z.object({
models: z.custom(),
allowed_routes: z.custom(),
max_budget: z.custom(),
+ soft_budget: z.custom(),
budget_duration: z.custom(),
tpm_limit: z.custom(),
tpm_limit_type: z.custom(),
@@ -168,6 +172,7 @@ export const toSubmittedValues = (
models: values.models,
allowed_routes: values.allowed_routes,
max_budget: values.max_budget,
+ soft_budget: values.soft_budget,
budget_duration: values.budget_duration,
tpm_limit: values.tpm_limit,
tpm_limit_type: values.tpm_limit_type,
diff --git a/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx b/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx
index 4f732083c3e..35e9268e1c2 100644
--- a/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx
+++ b/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx
@@ -1885,6 +1885,7 @@ describe("KeyEditView", () => {
key_alias: "asdasdas",
models: [],
max_budget: 0,
+ soft_budget: null,
budget_duration: "30d",
tpm_limit: 10,
tpm_limit_type: null,
diff --git a/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx b/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx
index 0f955fee895..b1bbbeed6f9 100644
--- a/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx
+++ b/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx
@@ -32,7 +32,7 @@ import {
modelSentinelOptions,
parseAllowedRoutes,
} from "./keyEditFieldNormalizers";
-import { KeyTypeSelect, labelWithHint } from "./KeyEditViewControls";
+import { KeyBudgetNumberField, KeyTypeSelect, labelWithHint } from "./KeyEditViewControls";
import {
AgentsAndGroups,
KeyEditFormValues,
@@ -417,17 +417,19 @@ export function KeyEditView({
)}
-
- {({ ref: _ref, ...field }) => (
-
- )}
-
+
+
+
{({ value, onChange, id }) => (
diff --git a/ui/litellm-dashboard/src/components/templates/key_info_view.tsx b/ui/litellm-dashboard/src/components/templates/key_info_view.tsx
index f5c682a2ee0..969decb6613 100644
--- a/ui/litellm-dashboard/src/components/templates/key_info_view.tsx
+++ b/ui/litellm-dashboard/src/components/templates/key_info_view.tsx
@@ -218,6 +218,23 @@ export default function KeyInfoView({
// Handle max budget empty string
formValues.max_budget = mapEmptyStringToNull(formValues.max_budget);
+ // soft_budget is a budget change server-side (admin-gated); only send it when it changed
+ // so a non-admin edit of unrelated fields isn't blocked by that gate.
+ const previousSoftBudget =
+ (currentKeyData.litellm_budget_table as { soft_budget?: number | null } | null | undefined)?.soft_budget ??
+ null;
+ const nextSoftBudget =
+ formValues.soft_budget === "" || formValues.soft_budget == null ? null : Number(formValues.soft_budget);
+ if (nextSoftBudget !== null && !Number.isFinite(nextSoftBudget)) {
+ toast.error("Soft Budget must be a finite number");
+ return;
+ }
+ if (nextSoftBudget === previousSoftBudget) {
+ delete formValues.soft_budget;
+ } else {
+ formValues.soft_budget = nextSoftBudget;
+ }
+
// Handle object_permission updates
if (formValues.vector_stores !== undefined) {
formValues.object_permission = {
diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts
index 12e7eb801e6..9de988fde4c 100644
--- a/ui/litellm-dashboard/src/lib/http/schema.d.ts
+++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts
@@ -8039,7 +8039,7 @@ export interface paths {
* - model_max_budget: Optional[Dict[str, BudgetConfig]] - Model-specific budgets {"gpt-4": {"budget_limit": 0.0005, "time_period": "30d"}}
* - budget_fallbacks: Optional[Dict[str, List[str]]] - Per-model fallback chain tried in order when that model's own `model_max_budget` is exceeded, e.g. {"gpt-4o": ["gpt-4o-mini"]}.
* - budget_duration: Optional[str] - Budget reset period ("30d", "1h", etc.)
- * - soft_budget: Optional[float] - [TODO] Soft budget limit (warning vs. hard stop). Will trigger a slack alert when this soft budget is reached.
+ * - soft_budget: Optional[float] - Soft budget limit (warning vs. hard stop). Will trigger a slack alert when this soft budget is reached. Set to null to remove the soft budget.
* - max_parallel_requests: Optional[int] - Rate limit for parallel requests
* - metadata: Optional[dict] - Metadata for key. Example {"team": "core-infra", "app": "app2"}
* - tpm_limit: Optional[int] - Tokens per minute limit
@@ -37761,6 +37761,8 @@ export interface components {
rpm_limit?: number | null;
/** Rpm Limit Type */
rpm_limit_type?: ("guaranteed_throughput" | "best_effort_throughput" | "dynamic") | null;
+ /** Soft Budget */
+ soft_budget?: number | null;
/** Spend */
spend?: number | null;
/** Tag Rpm Limit */
From edeb93e727135b43bd5c50c25630279ea34a1502 Mon Sep 17 00:00:00 2001
From: Yuneng Jiang
Date: Mon, 7 Sep 2026 12:50:17 -0700
Subject: [PATCH 115/123] ci: prepare workflows for main default branch
---
.github/workflows/publish-basedpyright-base-counts.yml | 6 +++---
.github/workflows/sync-together-ai-models.yml | 6 ++++--
.github/workflows/test-litellm-ui-unit.yml | 1 +
3 files changed, 8 insertions(+), 5 deletions(-)
diff --git a/.github/workflows/publish-basedpyright-base-counts.yml b/.github/workflows/publish-basedpyright-base-counts.yml
index cd443a8e9db..27d4682dbd9 100644
--- a/.github/workflows/publish-basedpyright-base-counts.yml
+++ b/.github/workflows/publish-basedpyright-base-counts.yml
@@ -1,6 +1,6 @@
name: Publish basedpyright base counts
-# Every commit on litellm_internal_staging is some branch's future merge-base.
+# Every commit on main or litellm_internal_staging can become a future merge-base.
# Publishing its per-rule basedpyright counts as an artifact lets
# scripts/type_check_gate.py download them in seconds instead of paying a
# 60-110s second basedpyright pass on every fresh worktree or moved merge-base.
@@ -10,13 +10,13 @@ name: Publish basedpyright base counts
on:
push:
branches:
+ - main
- litellm_internal_staging
workflow_dispatch:
inputs:
ref:
- description: "Ref to compute and publish base counts for"
+ description: "Ref to compute and publish base counts for (defaults to the workflow run's commit)"
required: false
- default: litellm_internal_staging
permissions:
contents: read
diff --git a/.github/workflows/sync-together-ai-models.yml b/.github/workflows/sync-together-ai-models.yml
index 1daaadeabe2..18d5e3de1eb 100644
--- a/.github/workflows/sync-together-ai-models.yml
+++ b/.github/workflows/sync-together-ai-models.yml
@@ -13,10 +13,12 @@ jobs:
sync_together_ai_models:
if: github.repository == 'BerriAI/litellm'
runs-on: ubuntu-latest
+ env:
+ BASE_BRANCH: ${{ github.event.repository.default_branch }}
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
- ref: litellm_internal_staging
+ ref: ${{ env.BASE_BRANCH }}
persist-credentials: false
- name: Set up uv
uses: ./.github/actions/setup-uv-with-retries
@@ -63,6 +65,6 @@ jobs:
gh pr create --title "feat(models): sync together_ai model registry" \
--body-file "$RUNNER_TEMP/pr_body.md" \
--head "$branch" \
- --base litellm_internal_staging
+ --base "$BASE_BRANCH"
env:
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
diff --git a/.github/workflows/test-litellm-ui-unit.yml b/.github/workflows/test-litellm-ui-unit.yml
index 314efcc49d5..cd58f861a87 100644
--- a/.github/workflows/test-litellm-ui-unit.yml
+++ b/.github/workflows/test-litellm-ui-unit.yml
@@ -12,6 +12,7 @@ on:
- "litellm_**"
push:
branches:
+ - main
- litellm_internal_staging
concurrency:
From e04e5d71138ea1356aacea1ea22ef060855dd596 Mon Sep 17 00:00:00 2001
From: yucheng-berri
Date: Mon, 7 Sep 2026 12:59:57 -0700
Subject: [PATCH 116/123] fix(router): keep provider response headers on
streaming chat completions (#40091)
* fix(router): keep provider response headers on streaming chat completions
The Router re-wraps a deployment's CustomStreamWrapper in FallbackStreamWrapper
(and its sync twin) so a mid-stream failure can fail over. Neither wrapper
forwarded `_response_headers`, so every streaming chat completion handed the
proxy's callbacks and its response-header builder a wrapper with no provider
headers, and a successful mid-stream fallback still published the failed
deployment's identity, `x-request-id` and rate limit counters.
Forward `_response_headers` into both wrappers, repoint the wrapper at the
deployment that served the stream once a fallback takes over, and rebuild the
proxy's response headers from that deployment while `create_response` still has
the first chunk buffered.
* fix(router): follow a nested fallback to the deployment that served the stream
A fallback the router picks is itself a fallback-aware wrapper, and it only
repoints at its own fallback once it yields, so reading its hidden params at
selection time named a deployment that produced no output. Re-read them when
the first fallback item arrives, which is still before the proxy commits
response headers.
Also addresses review feedback: the streaming header builder reads self.data
instead of taking a coarse request_data parameter, and the new test recorder
local is Final.
* test(router): cover the fallback header adoption helper directly
The router_code_coverage gate wants every router.py function named in a
router test, and this also pins the weak-reference behavior: a wrapper
collected mid-stream must not break the generator still draining it.
* refactor(proxy): take a read-only mapping for the model-id lookup
_get_model_id_from_response only reads its request payload, so a Mapping
says what it needs and the two metadata hops are narrowed instead of
assumed to be dicts.
* test: drop mutable recorder locals and routine comments from the new tests
An AsyncMock await_count and an asyncio.Event say the same thing as a
list and a dict that the test mutates.
* chore(router): justify the two rebinds in the fallback loops
Both are the one-shot re-read that follows a nested fallback, so they get
the repo's rebind-ok note like the rest of the file.
---
litellm/proxy/common_request_processing.py | 126 ++--
.../pass_through_endpoints.py | 2 +-
litellm/router.py | 83 ++-
.../proxy/test_common_request_processing.py | 247 +++++++-
tests/test_litellm/test_router.py | 574 ++++++++++++++++++
5 files changed, 985 insertions(+), 47 deletions(-)
diff --git a/litellm/proxy/common_request_processing.py b/litellm/proxy/common_request_processing.py
index 5e6c9b34332..9720e4b1cf8 100644
--- a/litellm/proxy/common_request_processing.py
+++ b/litellm/proxy/common_request_processing.py
@@ -756,7 +756,7 @@ class _UpstreamClosingStreamingResponse(StreamingResponse):
content: AsyncGenerator[str, None],
*,
media_type: str | None = None,
- headers: dict | None = None,
+ headers: Mapping[str, str] | None = None,
status_code: int = status.HTTP_200_OK,
upstream_generator: AsyncGenerator[str, None] | None = None,
) -> None:
@@ -888,25 +888,39 @@ def _sse_error_frames(error_obj: Mapping[str, object]) -> tuple[str, str]:
return f"data: {json.dumps({'error': error_obj})}\n\n", "data: [DONE]\n\n"
+def _sse_stream_headers(headers: Mapping[str, str]) -> Mapping[str, str]:
+ """`headers` plus the two that stop reverse proxies from buffering SSE (issue #28384)."""
+ return MappingProxyType({**headers, **_TTFT_KEEPALIVE_HEADERS})
+
+
+async def _resolve_stream_headers(
+ headers: Mapping[str, str], refresh_headers: Callable[[], Awaitable[Mapping[str, str]]] | None
+) -> Mapping[str, str]:
+ if refresh_headers is None:
+ return headers
+ try:
+ return await refresh_headers()
+ except Exception as e: # noqa: BLE001 # a stream whose first chunk is already paid for must not fail over its headers
+ verbose_proxy_logger.exception("Error refreshing streaming response headers: %s", e)
+ return headers
+
+
async def create_response(
generator: AsyncGenerator[str, None],
media_type: str,
- headers: dict,
+ headers: Mapping[str, str],
default_status_code: int = status.HTTP_200_OK,
request: Request | None = None,
+ refresh_headers: Callable[[], Awaitable[Mapping[str, str]]] | None = None,
) -> StreamingResponse | JSONResponse:
"""
Create streaming response, checking if the first chunk is an error.
If the first chunk is an error, return a standard JSON error response.
Otherwise, return StreamingResponse and stream all content.
+
+ ``refresh_headers`` is consulted once the first chunk has been buffered, for
+ callers whose headers can only be known then.
"""
- # Tell buffering reverse proxies (nginx, ingress-nginx, Envoy) to flush SSE
- # immediately instead of releasing the whole stream in one batch (issue #28384).
- streaming_headers: Final = {
- **headers,
- "Cache-Control": "no-cache",
- "X-Accel-Buffering": "no",
- }
first_chunk_value: str | None = None
final_status_code = default_status_code
@@ -917,6 +931,7 @@ async def create_response(
# Now get the first chunk from the actual generator
first_chunk_value = await _buffer_first_chunk_honoring_disconnect(generator, request)
+ resolved_headers: Final = await _resolve_stream_headers(headers, refresh_headers)
if first_chunk_value is not None:
try:
@@ -943,7 +958,7 @@ async def create_response(
return JSONResponse(
status_code=final_status_code,
content={"error": error_dict},
- headers=headers,
+ headers=resolved_headers,
)
except Exception as e:
verbose_proxy_logger.debug("Error parsing first chunk value: %s", e)
@@ -972,7 +987,7 @@ async def create_response(
return StreamingResponse(
empty_gen(),
media_type=media_type,
- headers=streaming_headers,
+ headers=_sse_stream_headers(await _resolve_stream_headers(headers, refresh_headers)),
status_code=default_status_code,
)
except Exception as e:
@@ -988,7 +1003,7 @@ async def create_response(
return StreamingResponse(
error_gen_message(),
media_type=media_type,
- headers=streaming_headers,
+ headers=_sse_stream_headers(await _resolve_stream_headers(headers, refresh_headers)),
status_code=error_status,
)
@@ -1010,7 +1025,7 @@ async def create_response(
return _UpstreamClosingStreamingResponse(
combined_generator(),
media_type=media_type,
- headers=streaming_headers,
+ headers=_sse_stream_headers(resolved_headers),
status_code=final_status_code,
upstream_generator=generator,
)
@@ -1535,7 +1550,7 @@ class ProxyBaseLLMRequestProcessing:
@staticmethod
def _merge_passthrough_streaming_headers(
response_headers: httpx.Headers | dict | None,
- custom_headers: dict,
+ custom_headers: Mapping[str, str],
) -> dict:
"""
Merge upstream passthrough headers with proxy/custom headers.
@@ -2143,14 +2158,45 @@ class ProxyBaseLLMRequestProcessing:
return fallback_model_group
@staticmethod
- def _get_model_id_from_response(hidden_params: dict, data: dict) -> str:
+ def _get_model_id_from_response(hidden_params: Mapping[str, object], data: Mapping[str, object]) -> str:
"""Extract model_id from hidden_params with fallback to litellm_metadata."""
model_id = hidden_params.get("model_id", None) or ""
if not model_id:
- litellm_metadata: Final = data.get("litellm_metadata", {}) or {}
- model_info: Final = litellm_metadata.get("model_info", {}) or {}
- model_id = model_info.get("id", "") or ""
- return model_id
+ litellm_metadata: Final = data.get("litellm_metadata")
+ model_info: Final = litellm_metadata.get("model_info") if isinstance(litellm_metadata, Mapping) else None
+ model_id = (model_info.get("id") or "") if isinstance(model_info, Mapping) else ""
+ return str(model_id) if model_id else ""
+
+ def _stream_response_headers(
+ self,
+ *,
+ hidden_params: Mapping[str, object],
+ user_api_key_dict: UserAPIKeyAuth,
+ logging_obj: LiteLLMLoggingObj,
+ version: str | None,
+ callback_headers: Mapping[str, str],
+ ) -> Mapping[str, str]:
+ """The streaming response headers describing `hidden_params`' deployment."""
+ return MappingProxyType(
+ {
+ **ProxyBaseLLMRequestProcessing.get_custom_headers(
+ user_api_key_dict=user_api_key_dict,
+ call_id=logging_obj.litellm_call_id,
+ model_id=self._get_model_id_from_response(hidden_params, self.data),
+ cache_key=hidden_params.get("cache_key") or "",
+ api_base=hidden_params.get("api_base") or "",
+ version=version,
+ response_cost=hidden_params.get("response_cost") or "",
+ model_region=getattr(user_api_key_dict, "allowed_model_region", ""),
+ fastest_response_batch_completion=hidden_params.get("fastest_response_batch_completion"),
+ request_data=self.data,
+ hidden_params=hidden_params,
+ litellm_logging_obj=logging_obj,
+ **(hidden_params.get("additional_headers") or MappingProxyType({})),
+ ),
+ **callback_headers,
+ }
+ )
@staticmethod
def _get_deployment_model_name(
@@ -2419,31 +2465,32 @@ class ProxyBaseLLMRequestProcessing:
if self._is_streaming_request(
data=self.data, is_streaming_request=is_streaming_request
) or self._is_streaming_response(response): # use generate_responses to stream responses
- custom_headers: Final = ProxyBaseLLMRequestProcessing.get_custom_headers(
- user_api_key_dict=user_api_key_dict,
- call_id=logging_obj.litellm_call_id,
- model_id=model_id,
- cache_key=cache_key,
- api_base=api_base,
- version=version,
- response_cost=response_cost,
- model_region=getattr(user_api_key_dict, "allowed_model_region", ""),
- fastest_response_batch_completion=fastest_response_batch_completion,
- request_data=self.data,
- hidden_params=hidden_params,
- litellm_logging_obj=logging_obj,
- **additional_headers,
- )
-
# Call response headers hook for streaming success
- callback_headers = await proxy_logging_obj.post_call_response_headers_hook(
+ stream_callback_headers: Final = await proxy_logging_obj.post_call_response_headers_hook(
data=self.data,
user_api_key_dict=user_api_key_dict,
response=response,
request_headers=dict(request.headers),
)
- if callback_headers:
- custom_headers.update(callback_headers)
+ custom_headers: Final = self._stream_response_headers(
+ hidden_params=hidden_params,
+ user_api_key_dict=user_api_key_dict,
+ logging_obj=logging_obj,
+ version=version,
+ callback_headers=stream_callback_headers or MappingProxyType({}),
+ )
+
+ async def refresh_stream_headers() -> Mapping[str, str]:
+ """`custom_headers` rebuilt for whichever deployment served the stream."""
+ if not getattr(response, "fallback_headers_adopted", False):
+ return custom_headers
+ return self._stream_response_headers(
+ hidden_params=get_hidden_params_dict(response),
+ user_api_key_dict=user_api_key_dict,
+ logging_obj=logging_obj,
+ version=version,
+ callback_headers=stream_callback_headers or MappingProxyType({}),
+ )
# Preserve the original client-requested model (pre-alias mapping) for downstream
# streaming generators. Pre-call processing can rewrite `self.data["model"]` for
@@ -2581,6 +2628,7 @@ class ProxyBaseLLMRequestProcessing:
media_type="text/event-stream",
headers=custom_headers,
request=request,
+ refresh_headers=refresh_stream_headers,
)
### CALL HOOKS ### - modify outgoing data
@@ -3032,7 +3080,7 @@ class ProxyBaseLLMRequestProcessing:
response: Any,
proxy_logging_obj: "ProxyLogging",
user_api_key_dict: "UserAPIKeyAuth",
- custom_headers: dict,
+ custom_headers: Mapping[str, str],
request_headers: dict[str, str],
) -> Response | None:
if not self._has_post_call_guardrails_for_passthrough():
diff --git a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py
index 3cb9acc6110..f1f823e59b5 100644
--- a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py
+++ b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py
@@ -322,7 +322,7 @@ class HttpPassThroughEndpointHelpers(BasePassthroughUtils):
def get_response_headers(
headers: httpx.Headers,
litellm_call_id: str | None = None,
- custom_headers: dict | None = None,
+ custom_headers: Mapping[str, str] | None = None,
) -> dict:
# Exclude headers that uvicorn writes itself (server, date) and
# encoding/length headers that don't survive re-serialization.
diff --git a/litellm/router.py b/litellm/router.py
index c7a254cbc02..95cabfad4bd 100644
--- a/litellm/router.py
+++ b/litellm/router.py
@@ -616,6 +616,38 @@ RETRY_BREADCRUMB_EXCLUDED_KWARGS: Final = frozenset(
RETRY_BREADCRUMB_LIMIT: Final = 4
+class FallbackAwareStreamWrapper(CustomStreamWrapper):
+ """Base for the Router's chat-completion stream wrappers, which are built around the
+ attempt the Router picked first and have to repoint themselves when a fallback takes over."""
+
+ fallback_headers_adopted: bool = False
+
+ def adopt_fallback_response_headers(
+ self,
+ fallback_response: object,
+ prepared_fallback_hidden_params: tuple[dict[str, object], dict[str, object]],
+ ) -> None:
+ """Repoint this wrapper at the deployment that served the stream.
+
+ Replaces rather than merges, so the failed attempt's `x-request-id`, rate limit
+ counters, `model_id` and `api_base` cannot reach the proxy's response headers or
+ its callbacks.
+ """
+ self._response_headers = getattr(fallback_response, "_response_headers", None)
+ fallback_hidden_params, fallback_headers = prepared_fallback_hidden_params
+ if fallback_hidden_params:
+ self._hidden_params = { # mutable-ok: the rest of litellm writes into _hidden_params
+ **fallback_hidden_params,
+ # dict() because add_retry_fallback_headers mutates additional_headers in place
+ "additional_headers": dict(fallback_headers), # mutable-ok: see above
+ }
+ self._base_hidden_params = { # mutable-ok: CustomStreamWrapper keeps this snapshot as a dict
+ **self._hidden_params,
+ "response_cost": None,
+ }
+ self.fallback_headers_adopted = True
+
+
class Router:
model_names: set = set()
cache_responses: bool | None = False
@@ -2576,6 +2608,18 @@ class Router:
return fallback_hidden_params, {}
return fallback_hidden_params, cast("dict[str, object]", fallback_headers)
+ @staticmethod
+ def _adopt_fallback_response_headers(
+ wrapper_ref: "weakref.ref[FallbackAwareStreamWrapper]",
+ fallback_response: object,
+ ) -> tuple[dict[str, object], dict[str, object]]:
+ """Repoint the wrapper at `fallback_response`, returning its prepared hidden params."""
+ prepared: Final = Router._prepare_fallback_hidden_params(fallback_response)
+ adopting_wrapper: Final = wrapper_ref()
+ if adopting_wrapper is not None:
+ adopting_wrapper.adopt_fallback_response_headers(fallback_response, prepared)
+ return prepared
+
@staticmethod
def _apply_fallback_hidden_params_to_item(
fallback_item: object,
@@ -2615,7 +2659,7 @@ class Router:
held_slot: Final = deployment_slot if deployment_slot is not None else contextlib.AsyncExitStack()
- class FallbackStreamWrapper(CustomStreamWrapper):
+ class FallbackStreamWrapper(FallbackAwareStreamWrapper):
def __init__(self, async_generator: AsyncGenerator):
# Copy attributes from the original model_response
super().__init__(
@@ -2623,6 +2667,7 @@ class Router:
model=model_response.model,
custom_llm_provider=model_response.custom_llm_provider,
logging_obj=model_response.logging_obj,
+ _response_headers=getattr(model_response, "_response_headers", None),
)
self._async_generator = async_generator
inner_chunks: Final[object] = getattr(model_response, "chunks", None)
@@ -2699,8 +2744,17 @@ class Router:
# If fallback returns a streaming response, iterate over it
if hasattr(fallback_response, "__aiter__"):
- prepared_fallback_hidden_params = Router._prepare_fallback_hidden_params(fallback_response)
+ prepared_fallback_hidden_params = Router._adopt_fallback_response_headers(
+ wrapper_ref, fallback_response
+ )
+ fallback_headers_are_settled = False
async for fallback_item in fallback_response:
+ if not fallback_headers_are_settled:
+ fallback_headers_are_settled = True # rebind-ok: one-shot latch
+ # a fallback that failed over again only repoints itself once it yields
+ prepared_fallback_hidden_params = ( # rebind-ok: re-read once the fallback yields
+ Router._adopt_fallback_response_headers(wrapper_ref, fallback_response)
+ )
Router._apply_fallback_hidden_params_to_item(fallback_item, prepared_fallback_hidden_params)
if (
fallback_item
@@ -2742,7 +2796,11 @@ class Router:
e,
)
- return FallbackStreamWrapper(stream_with_fallbacks())
+ wrapped_response: Final = FallbackStreamWrapper(stream_with_fallbacks())
+ # weak, so the generator closing over it does not keep the wrapper out of
+ # refcount teardown and delay the `finally` that releases the deployment slot
+ wrapper_ref: Final = weakref.ref(wrapped_response)
+ return wrapped_response
@staticmethod
def _extract_partial_responses_usage(
@@ -3171,13 +3229,14 @@ class Router:
"""
from litellm.exceptions import MidStreamFallbackError
- class SyncFallbackStreamWrapper(CustomStreamWrapper):
+ class SyncFallbackStreamWrapper(FallbackAwareStreamWrapper):
def __init__(self, sync_generator: Generator):
super().__init__(
completion_stream=sync_generator,
model=model_response.model,
custom_llm_provider=model_response.custom_llm_provider,
logging_obj=model_response.logging_obj,
+ _response_headers=getattr(model_response, "_response_headers", None),
)
self._sync_generator = sync_generator
if hasattr(model_response, "_hidden_params"):
@@ -3233,8 +3292,17 @@ class Router:
)
if hasattr(fallback_response, "__iter__"):
- prepared_fallback_hidden_params = Router._prepare_fallback_hidden_params(fallback_response)
+ prepared_fallback_hidden_params = Router._adopt_fallback_response_headers(
+ wrapper_ref, fallback_response
+ )
+ fallback_headers_are_settled = False
for fallback_item in fallback_response:
+ if not fallback_headers_are_settled:
+ fallback_headers_are_settled = True # rebind-ok: one-shot latch
+ # a fallback that failed over again only repoints itself once it yields
+ prepared_fallback_hidden_params = ( # rebind-ok: re-read once the fallback yields
+ Router._adopt_fallback_response_headers(wrapper_ref, fallback_response)
+ )
Router._apply_fallback_hidden_params_to_item(fallback_item, prepared_fallback_hidden_params)
if (
fallback_item
@@ -3272,7 +3340,10 @@ class Router:
close_err,
)
- return SyncFallbackStreamWrapper(stream_with_fallbacks())
+ wrapped_response: Final = SyncFallbackStreamWrapper(stream_with_fallbacks())
+ # weak, for the same reason as the async twin
+ wrapper_ref: Final = weakref.ref(wrapped_response)
+ return wrapped_response
async def _silent_experiment_acompletion(self, silent_model: str, messages: Sequence[Mapping[str, str]], **kwargs):
"""
diff --git a/tests/test_litellm/proxy/test_common_request_processing.py b/tests/test_litellm/proxy/test_common_request_processing.py
index 96d9b0c5a26..6acd9d7258e 100644
--- a/tests/test_litellm/proxy/test_common_request_processing.py
+++ b/tests/test_litellm/proxy/test_common_request_processing.py
@@ -2,7 +2,7 @@ import asyncio
import copy
import datetime
import json
-from types import SimpleNamespace
+from types import MappingProxyType, SimpleNamespace
from typing import AsyncGenerator, Callable, Final, Optional
from unittest.mock import AsyncMock, MagicMock, patch
@@ -1809,6 +1809,146 @@ class TestCommonRequestProcessingHelpers:
response = await create_response(mock_generator(), "text/event-stream", custom_headers)
assert response.headers["x-custom-header"] == "TestValue"
+ async def test_create_streaming_response_refresh_headers_after_first_chunk(self):
+ """LIT-6767: headers a caller can only resolve once the first chunk exists.
+
+ A pre-first-chunk fallback replaces the deployment while the response
+ headers are still uncommitted, so ``refresh_headers`` is consulted after
+ the first chunk is buffered and its result wins.
+ """
+
+ async def mock_generator():
+ yield 'data: {"content": "data"}\n\n'
+ yield "data: [DONE]\n\n"
+
+ refresh_headers: Final = AsyncMock(
+ return_value={"x-litellm-model-id": "fallback-deployment", "llm_provider-x-request-id": "req-FALLBACK"}
+ )
+
+ response = await create_response(
+ mock_generator(),
+ "text/event-stream",
+ {"x-litellm-model-id": "failed-deployment", "llm_provider-x-request-id": "req-FAILED"},
+ refresh_headers=refresh_headers,
+ )
+ assert isinstance(response, StreamingResponse)
+ assert refresh_headers.await_count == 1
+ assert response.headers["x-litellm-model-id"] == "fallback-deployment"
+ assert response.headers["llm_provider-x-request-id"] == "req-FALLBACK"
+ # the buffering headers are still applied on top of the refreshed set
+ assert response.headers["x-accel-buffering"] == "no"
+ assert response.headers["cache-control"] == "no-cache"
+
+ async def test_create_streaming_response_refreshes_only_after_the_first_chunk(self):
+ """LIT-6767: the refresh has to be consulted after the generator produced a chunk.
+
+ A pre-first-chunk fallback only repoints the response while that first chunk is
+ being produced, so a refresh consulted any earlier still describes the attempt
+ that failed and the headers go out wrong.
+ """
+ first_chunk_produced: Final = asyncio.Event()
+
+ async def mock_generator():
+ first_chunk_produced.set()
+ yield 'data: {"content": "data"}\n\n'
+ yield "data: [DONE]\n\n"
+
+ async def refresh_headers():
+ served = "fallback-deployment" if first_chunk_produced.is_set() else "failed-deployment"
+ return {"x-litellm-model-id": served}
+
+ response = await create_response(
+ mock_generator(),
+ "text/event-stream",
+ {"x-litellm-model-id": "failed-deployment"},
+ refresh_headers=refresh_headers,
+ )
+ assert response.headers["x-litellm-model-id"] == "fallback-deployment"
+
+ async def test_create_streaming_response_empty_stream_uses_refreshed_headers(self):
+ """LIT-6767: a fallback that served nothing still gets to name itself.
+
+ The empty-generator branch returns its own StreamingResponse, so it needs the
+ refreshed headers too or the client is told the failed deployment answered.
+ """
+
+ async def mock_generator():
+ return
+ yield # make it an async generator
+
+ async def refresh_headers():
+ return {"x-litellm-model-id": "fallback-deployment"}
+
+ response = await create_response(
+ mock_generator(),
+ "text/event-stream",
+ {"x-litellm-model-id": "failed-deployment"},
+ refresh_headers=refresh_headers,
+ )
+ assert isinstance(response, StreamingResponse)
+ assert response.headers["x-litellm-model-id"] == "fallback-deployment"
+ assert response.headers["x-accel-buffering"] == "no"
+
+ async def test_create_streaming_response_without_refresh_headers_is_unchanged(self):
+ """LIT-6767: the default keeps the caller-supplied headers verbatim."""
+
+ async def mock_generator():
+ yield 'data: {"content": "data"}\n\n'
+ yield "data: [DONE]\n\n"
+
+ response = await create_response(
+ mock_generator(),
+ "text/event-stream",
+ {"x-litellm-model-id": "failed-deployment"},
+ )
+ assert response.headers["x-litellm-model-id"] == "failed-deployment"
+
+ async def test_create_streaming_response_refresh_headers_failure_keeps_stream(self):
+ """LIT-6767: the first chunk is already paid for, so a failing refresh
+ falls back to the caller's headers instead of erroring the stream."""
+
+ async def mock_generator():
+ yield 'data: {"content": "data"}\n\n'
+ yield "data: [DONE]\n\n"
+
+ async def refresh_headers():
+ raise RuntimeError("boom")
+
+ response = await create_response(
+ mock_generator(),
+ "text/event-stream",
+ {"x-litellm-model-id": "failed-deployment"},
+ refresh_headers=refresh_headers,
+ )
+ assert isinstance(response, StreamingResponse)
+ assert response.status_code == status.HTTP_200_OK
+ assert response.headers["x-litellm-model-id"] == "failed-deployment"
+ assert await self.consume_stream(response) == [
+ 'data: {"content": "data"}\n\n',
+ "data: [DONE]\n\n",
+ ]
+
+ async def test_create_response_first_chunk_error_uses_refreshed_headers(self):
+ """LIT-6767: the JSON error response built from a bad first chunk carries
+ the refreshed headers too, so it cannot describe a deployment that no
+ longer served the request."""
+
+ async def mock_generator():
+ yield 'data: {"error": {"code": 403, "message": "forbidden"}}\n\n'
+ yield "data: [DONE]\n\n"
+
+ async def refresh_headers():
+ return {"x-litellm-model-id": "fallback-deployment"}
+
+ response = await create_response(
+ mock_generator(),
+ "text/event-stream",
+ {"x-litellm-model-id": "failed-deployment"},
+ refresh_headers=refresh_headers,
+ )
+ assert isinstance(response, JSONResponse)
+ assert response.headers["x-litellm-model-id"] == "fallback-deployment"
+
async def test_create_streaming_response_disables_proxy_buffering(self):
"""Regression for #28384: every StreamingResponse create_response returns
must carry the headers that stop nginx/ingress/Envoy from buffering the
@@ -8014,3 +8154,108 @@ class TestDetachedStreamFailureHook:
await logging_obj._on_detached_stream_failure(failure)
assert [call["original_exception"] for call in recorder.calls] == [failure]
+
+
+class TestStreamingResponseHeadersFollowFallback:
+ """LIT-6767: the streaming branch has to publish the deployment that served the stream."""
+
+ @staticmethod
+ def _fallback_adopting_stream():
+ class _Stream:
+ def __init__(self) -> None:
+ self._hidden_params = {
+ "model_id": "failed-deployment",
+ "api_base": "http://127.0.0.1:20769/v1",
+ "additional_headers": {"llm_provider-stale-marker": "failed-deployment"},
+ }
+ self.fallback_headers_adopted = False
+
+ def adopt(self) -> None:
+ self._hidden_params = {
+ "model_id": "served-deployment",
+ "api_base": "https://api.openai.com",
+ "additional_headers": {"llm_provider-x-request-id": "req-SERVED"},
+ }
+ self.fallback_headers_adopted = True
+
+ return _Stream()
+
+ @pytest.mark.asyncio
+ async def test_streaming_headers_name_the_deployment_that_served(self, monkeypatch):
+ """A pre-first-chunk fallback repoints the stream while the headers are still
+ uncommitted, so the published headers must describe the fallback, not the attempt
+ the Router picked first."""
+ stream = self._fallback_adopting_stream()
+
+ def select_data_generator(**kwargs):
+ async def generator():
+ stream.adopt()
+ yield 'data: {"choices": [{"delta": {"content": "OK"}}]}\n\n'
+ yield "data: [DONE]\n\n"
+
+ return generator()
+
+ logging_obj = MagicMock()
+ logging_obj.litellm_call_id = "lit-6767-call"
+ logging_obj._defer_async_logging = False
+ logging_obj._on_deferred_stream_complete = None
+ logging_obj.cost_breakdown = None
+
+ processor = ProxyBaseLLMRequestProcessing(
+ data={"model": "oa-midfail", "stream": True, "litellm_logging_obj": logging_obj}
+ )
+
+ proxy_logging_obj = MagicMock(spec=ProxyLogging)
+ proxy_logging_obj.during_call_hook = AsyncMock(return_value=None)
+ proxy_logging_obj.update_request_status = AsyncMock(return_value=None)
+ proxy_logging_obj.post_call_success_hook = AsyncMock(
+ side_effect=lambda data, user_api_key_dict, response: response
+ )
+ proxy_logging_obj.post_call_response_headers_hook = AsyncMock(
+ return_value={"x-callback-header": "kept"}
+ )
+
+ async def fake_route_request(**kwargs):
+ async def call():
+ return stream
+
+ return call()
+
+ monkeypatch.setattr(
+ litellm.proxy.common_request_processing, "route_request", fake_route_request
+ )
+
+ result = await processor.base_process_llm_request(
+ request=Request(scope={"type": "http", "headers": []}),
+ fastapi_response=Response(),
+ user_api_key_dict=ProxyUserAPIKeyAuth(api_key="sk-test"),
+ route_type="acompletion",
+ proxy_logging_obj=proxy_logging_obj,
+ general_settings={},
+ proxy_config=MagicMock(spec=ProxyConfig),
+ select_data_generator=select_data_generator,
+ is_streaming_request=True,
+ skip_pre_call_logic=True,
+ )
+
+ assert isinstance(result, StreamingResponse)
+ assert result.headers["x-litellm-model-id"] == "served-deployment"
+ assert result.headers["x-litellm-model-api-base"] == "https://api.openai.com"
+ assert result.headers["llm_provider-x-request-id"] == "req-SERVED"
+ assert "llm_provider-stale-marker" not in result.headers
+ assert result.headers["x-callback-header"] == "kept"
+
+
+class TestPassthroughHeadersAcceptImmutableMappings:
+ """LIT-6767: the streaming branch now hands the passthrough helpers an immutable mapping."""
+
+ def test_merge_passthrough_streaming_headers_accepts_a_read_only_mapping(self):
+ merged = ProxyBaseLLMRequestProcessing._merge_passthrough_streaming_headers(
+ response_headers=httpx.Headers({"content-type": "text/event-stream", "transfer-encoding": "chunked"}),
+ custom_headers=MappingProxyType({"x-litellm-model-id": "served-deployment"}),
+ )
+
+ assert merged["x-litellm-model-id"] == "served-deployment"
+ assert merged["content-type"] == "text/event-stream"
+ # the excluded hop-by-hop header is still dropped
+ assert "transfer-encoding" not in merged
diff --git a/tests/test_litellm/test_router.py b/tests/test_litellm/test_router.py
index e9b77076448..eed34c79a06 100644
--- a/tests/test_litellm/test_router.py
+++ b/tests/test_litellm/test_router.py
@@ -2387,6 +2387,580 @@ async def test_acompletion_streaming_iterator_preserves_hidden_params():
assert result._hidden_params.get("_response_ms") == 500.0
+@pytest.mark.asyncio
+async def test_acompletion_streaming_iterator_preserves_response_headers():
+ """LIT-6767: the returned wrapper must carry the provider's raw response headers.
+
+ Proxy callbacks read ``_response_headers`` off the object the router hands
+ back. The wrapper used to be built without it, so every streaming chat
+ completion reported zero raw provider headers while the non-streaming path
+ reported the full set.
+ """
+ from unittest.mock import MagicMock
+
+ from litellm.litellm_core_utils.streaming_handler import CustomStreamWrapper
+
+ router = litellm.Router(
+ model_list=[
+ {
+ "model_name": "gpt-4",
+ "litellm_params": {"model": "gpt-4", "api_key": "fake-key"},
+ }
+ ],
+ )
+
+ async def _empty():
+ return
+ yield # make it an async generator
+
+ provider_headers = {
+ "x-request-id": "req-provider-123",
+ "x-ratelimit-remaining-requests": "42",
+ # a provider must never be able to spoof an internal header
+ "x-litellm-model-id": "spoofed",
+ }
+ source = CustomStreamWrapper(
+ completion_stream=_empty(),
+ model="gpt-4",
+ custom_llm_provider="openai",
+ logging_obj=MagicMock(),
+ _response_headers=provider_headers,
+ )
+
+ result = await router._acompletion_streaming_iterator(
+ model_response=source,
+ messages=[{"role": "user", "content": "hi"}],
+ initial_kwargs={"model": "gpt-4", "stream": True},
+ )
+
+ assert result._response_headers == provider_headers
+ additional_headers = result._hidden_params["additional_headers"]
+ assert additional_headers["llm_provider-x-request-id"] == "req-provider-123"
+ assert additional_headers["llm_provider-x-ratelimit-remaining-requests"] == "42"
+ # internal-header protection survives: the provider value is namespaced, never promoted
+ assert additional_headers["llm_provider-x-litellm-model-id"] == "spoofed"
+ assert "x-litellm-model-id" not in additional_headers
+
+
+def test_completion_streaming_iterator_preserves_response_headers():
+ """LIT-6767, sync counterpart of the async header-preservation test."""
+ from unittest.mock import MagicMock
+
+ from litellm.litellm_core_utils.streaming_handler import CustomStreamWrapper
+
+ router = litellm.Router(
+ model_list=[
+ {
+ "model_name": "gpt-4",
+ "litellm_params": {"model": "gpt-4", "api_key": "fake-key"},
+ }
+ ],
+ )
+
+ provider_headers = {"x-request-id": "req-provider-sync", "openai-organization": "org-real"}
+ source = CustomStreamWrapper(
+ completion_stream=iter([]),
+ model="gpt-4",
+ custom_llm_provider="openai",
+ logging_obj=MagicMock(),
+ _response_headers=provider_headers,
+ )
+
+ result = router._completion_streaming_iterator(
+ model_response=source,
+ messages=[{"role": "user", "content": "hi"}],
+ initial_kwargs={"model": "gpt-4", "stream": True},
+ )
+
+ assert result._response_headers == provider_headers
+ assert result._hidden_params["additional_headers"]["llm_provider-x-request-id"] == "req-provider-sync"
+
+
+def test_adopt_fallback_response_headers_replaces_rather_than_merges():
+ """LIT-6767: direct unit for FallbackAwareStreamWrapper.adopt_fallback_response_headers.
+
+ Values from the failed attempt must not survive, so the wrapper replaces both
+ ``_response_headers`` and ``_hidden_params`` instead of merging them.
+ """
+ from unittest.mock import MagicMock
+
+ from litellm.router import FallbackAwareStreamWrapper, Router
+
+ wrapper = FallbackAwareStreamWrapper(
+ completion_stream=iter([]),
+ model="gpt-4",
+ custom_llm_provider="openai",
+ logging_obj=MagicMock(),
+ _response_headers={"x-request-id": "req-FAILED"},
+ )
+ wrapper._hidden_params = {
+ "model_id": "failed-deployment",
+ "only_on_failed_attempt": "stale",
+ "additional_headers": {"llm_provider-x-request-id": "req-FAILED"},
+ }
+
+ fallback = MagicMock()
+ fallback._response_headers = {"x-request-id": "req-FALLBACK"}
+ fallback._hidden_params = {
+ "model_id": "fallback-deployment",
+ "additional_headers": {"llm_provider-x-request-id": "req-FALLBACK"},
+ }
+
+ wrapper.adopt_fallback_response_headers(
+ fallback, Router._prepare_fallback_hidden_params(fallback)
+ )
+
+ assert wrapper._response_headers == {"x-request-id": "req-FALLBACK"}
+ assert wrapper._hidden_params["model_id"] == "fallback-deployment"
+ assert "only_on_failed_attempt" not in wrapper._hidden_params
+ assert wrapper._hidden_params is not fallback._hidden_params
+ # the snapshot CustomStreamWrapper caches at init has to follow, or a chunk built
+ # from it would still be stamped with the deployment that failed
+ assert wrapper._base_hidden_params["model_id"] == "fallback-deployment"
+ # the nested header dict is copied too, so a later mutation on the fallback
+ # response cannot reach headers the proxy has already published
+ assert wrapper._hidden_params["additional_headers"] is not fallback._hidden_params["additional_headers"]
+ fallback._hidden_params["additional_headers"]["llm_provider-x-request-id"] = "req-MUTATED"
+ assert wrapper._hidden_params["additional_headers"] == {"llm_provider-x-request-id": "req-FALLBACK"}
+
+
+def test_adopt_fallback_response_headers_survives_a_collected_wrapper():
+ """LIT-6767: adoption still returns the fallback's params once the wrapper is gone."""
+ import weakref
+ from unittest.mock import MagicMock
+
+ from litellm.router import FallbackAwareStreamWrapper, Router
+
+ fallback: Final = MagicMock()
+ fallback._response_headers = {"x-request-id": "req-FALLBACK"}
+ fallback._hidden_params = {
+ "model_id": "fallback-deployment",
+ "additional_headers": {"llm_provider-x-request-id": "req-FALLBACK"},
+ }
+
+ wrapper = FallbackAwareStreamWrapper(
+ completion_stream=iter([]),
+ model="gpt-4",
+ custom_llm_provider="openai",
+ logging_obj=MagicMock(),
+ )
+ live_ref: Final = weakref.ref(wrapper)
+ prepared: Final = Router._adopt_fallback_response_headers(live_ref, fallback)
+ assert prepared == (fallback._hidden_params, fallback._hidden_params["additional_headers"])
+ assert wrapper.fallback_headers_adopted is True
+ assert wrapper._response_headers == {"x-request-id": "req-FALLBACK"}
+
+ dead_ref: Final = weakref.ref(wrapper)
+ del wrapper
+ assert dead_ref() is None
+ assert Router._adopt_fallback_response_headers(dead_ref, fallback) == prepared
+
+
+def test_adopt_fallback_response_headers_drops_headers_the_fallback_cannot_replace():
+ """LIT-6767: a fallback that carries no raw provider headers publishes none.
+
+ Keeping the failed attempt's raw headers would hand the client and the callbacks a
+ provider ``x-request-id`` for a request that deployment never served, which is the
+ leak this fix exists to close.
+ """
+ from unittest.mock import MagicMock
+
+ from litellm.router import FallbackAwareStreamWrapper, Router
+
+ wrapper = FallbackAwareStreamWrapper(
+ completion_stream=iter([]),
+ model="gpt-4",
+ custom_llm_provider="openai",
+ logging_obj=MagicMock(),
+ _response_headers={"x-request-id": "req-FAILED"},
+ )
+ wrapper._hidden_params = {"model_id": "failed-deployment", "additional_headers": {}}
+
+ fallback = MagicMock()
+ fallback._response_headers = None
+ fallback._hidden_params = {"model_id": "fallback-deployment"}
+
+ wrapper.adopt_fallback_response_headers(
+ fallback, Router._prepare_fallback_hidden_params(fallback)
+ )
+
+ assert wrapper._response_headers is None
+ assert wrapper._hidden_params["model_id"] == "fallback-deployment"
+ assert wrapper.fallback_headers_adopted is True
+
+
+def test_adopt_fallback_response_headers_keeps_identity_when_fallback_has_none():
+ """A fallback response carrying no hidden params keeps the identity headers.
+
+ Publishing no ``x-litellm-*`` header at all for a request the fallback served is
+ worse than keeping what is there, so only the raw provider headers are dropped.
+ """
+ from unittest.mock import MagicMock
+
+ from litellm.router import FallbackAwareStreamWrapper, Router
+
+ wrapper = FallbackAwareStreamWrapper(
+ completion_stream=iter([]),
+ model="gpt-4",
+ custom_llm_provider="openai",
+ logging_obj=MagicMock(),
+ _response_headers={"x-request-id": "req-FAILED"},
+ )
+ hidden_params_before = wrapper._hidden_params
+
+ fallback = object()
+ wrapper.adopt_fallback_response_headers(
+ fallback, Router._prepare_fallback_hidden_params(fallback)
+ )
+
+ assert wrapper._response_headers is None
+ assert wrapper._hidden_params is hidden_params_before
+ assert wrapper.fallback_headers_adopted is True
+
+
+@pytest.mark.asyncio
+async def test_acompletion_streaming_iterator_adopts_fallback_response_headers():
+ """LIT-6767: after a successful pre-first-chunk fallback, the wrapper must
+ describe the deployment that served the stream, with no value left over
+ from the attempt that failed."""
+ from unittest.mock import MagicMock, patch
+
+ from litellm.exceptions import MidStreamFallbackError
+
+ router = litellm.Router(
+ model_list=[
+ {
+ "model_name": "gpt-4",
+ "litellm_params": {"model": "gpt-4", "api_key": "fake-key"},
+ }
+ ],
+ )
+
+ failed_error = MidStreamFallbackError(
+ message="upstream died before the first chunk",
+ model="gpt-4",
+ llm_provider="openai",
+ generated_content="",
+ is_pre_first_chunk=True,
+ )
+
+ class FailedStream:
+ def __init__(self):
+ self.model = "gpt-4"
+ self.custom_llm_provider = "openai"
+ self.logging_obj = MagicMock()
+ self.chunks = []
+ self._response_headers = {"x-request-id": "req-FAILED"}
+ self._hidden_params = {
+ "model_id": "failed-deployment",
+ "api_base": "https://failed.example",
+ "additional_headers": {"llm_provider-x-request-id": "req-FAILED"},
+ "only_on_failed_attempt": "stale",
+ }
+
+ def __aiter__(self):
+ return self
+
+ async def __anext__(self):
+ raise failed_error
+
+ class FallbackStream:
+ def __init__(self):
+ self._response_headers = {"x-request-id": "req-FALLBACK"}
+ self._hidden_params = {
+ "model_id": "fallback-deployment",
+ "api_base": "https://fallback.example",
+ "additional_headers": {"llm_provider-x-request-id": "req-FALLBACK"},
+ }
+ self._chunks = iter([litellm.ModelResponseStream(choices=[{"index": 0, "delta": {"content": "OK"}}])])
+
+ def __aiter__(self):
+ return self
+
+ async def __anext__(self):
+ try:
+ return next(self._chunks)
+ except StopIteration:
+ raise StopAsyncIteration from None
+
+ fallback_stream = FallbackStream()
+ with patch.object(
+ router,
+ "async_function_with_fallbacks_common_utils",
+ return_value=fallback_stream,
+ ):
+ result = await router._acompletion_streaming_iterator(
+ model_response=FailedStream(),
+ messages=[{"role": "user", "content": "hi"}],
+ initial_kwargs={"model": "gpt-4", "stream": True},
+ )
+ # the failed attempt is what the wrapper is built from
+ assert result._response_headers == {"x-request-id": "req-FAILED"}
+ # the very first chunk the fallback produces must already be published under
+ # the fallback's identity: the proxy commits response headers once that chunk
+ # is buffered, so adopting any later is adopting too late
+ await result.__anext__()
+ assert result._response_headers == {"x-request-id": "req-FALLBACK"}
+ assert result._hidden_params["model_id"] == "fallback-deployment"
+ async for _ in result:
+ pass
+
+ assert result._response_headers == {"x-request-id": "req-FALLBACK"}
+ assert result._hidden_params["model_id"] == "fallback-deployment"
+ assert result._hidden_params["api_base"] == "https://fallback.example"
+ assert result._hidden_params["additional_headers"] == {"llm_provider-x-request-id": "req-FALLBACK"}
+ # stale values are removed, not merged over
+ assert "only_on_failed_attempt" not in result._hidden_params
+ # and the wrapper holds its own copy, so later fallback mutations cannot leak in
+ assert result._hidden_params is not fallback_stream._hidden_params
+
+
+@pytest.mark.asyncio
+async def test_acompletion_streaming_iterator_adopts_the_deployment_that_served_a_nested_fallback():
+ """LIT-6767: a fallback that itself fails over before its first chunk.
+
+ The selected fallback still describes its own failed attempt at selection time, so
+ the wrapper has to re-read it once a chunk exists or it publishes a deployment that
+ produced no output.
+ """
+ from unittest.mock import MagicMock, patch
+
+ from litellm.exceptions import MidStreamFallbackError
+
+ router = litellm.Router(
+ model_list=[
+ {
+ "model_name": "gpt-4",
+ "litellm_params": {"model": "gpt-4", "api_key": "fake-key"},
+ }
+ ],
+ )
+
+ failed_error: Final = MidStreamFallbackError(
+ message="upstream died before the first chunk",
+ model="gpt-4",
+ llm_provider="openai",
+ generated_content="",
+ is_pre_first_chunk=True,
+ )
+
+ class FailedStream:
+ def __init__(self):
+ self.model = "gpt-4"
+ self.custom_llm_provider = "openai"
+ self.logging_obj = MagicMock()
+ self.chunks = []
+ self._response_headers = {"x-request-id": "req-FAILED"}
+ self._hidden_params = {
+ "model_id": "failed-deployment",
+ "additional_headers": {"llm_provider-x-request-id": "req-FAILED"},
+ }
+
+ def __aiter__(self):
+ return self
+
+ async def __anext__(self):
+ raise failed_error
+
+ class NestedFallbackStream:
+ """A fallback that repoints itself at a third deployment as it yields."""
+
+ def __init__(self):
+ self._response_headers = {"x-request-id": "req-MIDDLE"}
+ self._hidden_params = {
+ "model_id": "middle-deployment",
+ "additional_headers": {"llm_provider-x-request-id": "req-MIDDLE"},
+ }
+ self._chunks = iter([litellm.ModelResponseStream(choices=[{"index": 0, "delta": {"content": "OK"}}])])
+
+ def __aiter__(self):
+ return self
+
+ async def __anext__(self):
+ try:
+ chunk = next(self._chunks)
+ except StopIteration:
+ raise StopAsyncIteration from None
+ self._response_headers = {"x-request-id": "req-SERVED"}
+ self._hidden_params = {
+ "model_id": "served-deployment",
+ "additional_headers": {"llm_provider-x-request-id": "req-SERVED"},
+ }
+ return chunk
+
+ with patch.object(
+ router,
+ "async_function_with_fallbacks_common_utils",
+ return_value=NestedFallbackStream(),
+ ):
+ result = await router._acompletion_streaming_iterator(
+ model_response=FailedStream(),
+ messages=[{"role": "user", "content": "hi"}],
+ initial_kwargs={"model": "gpt-4", "stream": True},
+ )
+ first_chunk: Final = await result.__anext__()
+ # the proxy commits response headers once this chunk is buffered
+ assert result._response_headers == {"x-request-id": "req-SERVED"}
+ assert result._hidden_params["model_id"] == "served-deployment"
+ assert result._hidden_params["additional_headers"] == {"llm_provider-x-request-id": "req-SERVED"}
+ # and the chunk itself carries the same deployment
+ assert first_chunk._hidden_params["model_id"] == "served-deployment"
+ async for _ in result:
+ pass
+
+ assert result._response_headers == {"x-request-id": "req-SERVED"}
+ assert result._hidden_params["model_id"] == "served-deployment"
+
+
+def test_completion_streaming_iterator_adopts_the_deployment_that_served_a_nested_fallback():
+ """LIT-6767, sync counterpart of the nested-fallback adoption test."""
+ from unittest.mock import MagicMock, patch
+
+ from litellm.exceptions import MidStreamFallbackError
+
+ router = litellm.Router(
+ model_list=[
+ {
+ "model_name": "gpt-4",
+ "litellm_params": {"model": "gpt-4", "api_key": "fake-key"},
+ }
+ ],
+ )
+
+ failed_error: Final = MidStreamFallbackError(
+ message="upstream died before the first chunk",
+ model="gpt-4",
+ llm_provider="openai",
+ generated_content="",
+ is_pre_first_chunk=True,
+ )
+
+ class FailedStream:
+ def __init__(self):
+ self.model = "gpt-4"
+ self.custom_llm_provider = "openai"
+ self.logging_obj = MagicMock()
+ self.chunks = []
+ self._response_headers = {"x-request-id": "req-FAILED"}
+ self._hidden_params = {
+ "model_id": "failed-deployment",
+ "additional_headers": {"llm_provider-x-request-id": "req-FAILED"},
+ }
+
+ def __iter__(self):
+ return self
+
+ def __next__(self):
+ raise failed_error
+
+ class NestedFallbackStream:
+ """A fallback that repoints itself at a third deployment as it yields."""
+
+ def __init__(self):
+ self._response_headers = {"x-request-id": "req-MIDDLE"}
+ self._hidden_params = {
+ "model_id": "middle-deployment",
+ "additional_headers": {"llm_provider-x-request-id": "req-MIDDLE"},
+ }
+ self._chunks = iter([litellm.ModelResponseStream(choices=[{"index": 0, "delta": {"content": "OK"}}])])
+
+ def __iter__(self):
+ return self
+
+ def __next__(self):
+ chunk = next(self._chunks)
+ self._response_headers = {"x-request-id": "req-SERVED"}
+ self._hidden_params = {
+ "model_id": "served-deployment",
+ "additional_headers": {"llm_provider-x-request-id": "req-SERVED"},
+ }
+ return chunk
+
+ with patch.object(router, "function_with_fallbacks", return_value=NestedFallbackStream()):
+ result = router._completion_streaming_iterator(
+ model_response=FailedStream(),
+ messages=[{"role": "user", "content": "hi"}],
+ initial_kwargs={"model": "gpt-4", "stream": True},
+ )
+ first_chunk: Final = next(result)
+ assert result._response_headers == {"x-request-id": "req-SERVED"}
+ assert result._hidden_params["model_id"] == "served-deployment"
+ assert first_chunk._hidden_params["model_id"] == "served-deployment"
+ for _ in result:
+ pass
+
+ assert result._response_headers == {"x-request-id": "req-SERVED"}
+ assert result._hidden_params["model_id"] == "served-deployment"
+
+
+def test_completion_streaming_iterator_adopts_fallback_response_headers():
+ """LIT-6767, sync counterpart of the fallback-adoption test."""
+ from unittest.mock import MagicMock, patch
+
+ from litellm.exceptions import MidStreamFallbackError
+
+ router = litellm.Router(
+ model_list=[
+ {
+ "model_name": "gpt-4",
+ "litellm_params": {"model": "gpt-4", "api_key": "fake-key"},
+ }
+ ],
+ )
+
+ failed_error = MidStreamFallbackError(
+ message="upstream died before the first chunk",
+ model="gpt-4",
+ llm_provider="openai",
+ generated_content="",
+ is_pre_first_chunk=True,
+ )
+
+ class FailedStream:
+ def __init__(self):
+ self.model = "gpt-4"
+ self.custom_llm_provider = "openai"
+ self.logging_obj = MagicMock()
+ self.chunks = []
+ self._response_headers = {"x-request-id": "req-FAILED"}
+ self._hidden_params = {
+ "model_id": "failed-deployment",
+ "additional_headers": {"llm_provider-x-request-id": "req-FAILED"},
+ "only_on_failed_attempt": "stale",
+ }
+
+ def __iter__(self):
+ return self
+
+ def __next__(self):
+ raise failed_error
+
+ class FallbackStream:
+ def __init__(self):
+ self._response_headers = {"x-request-id": "req-FALLBACK"}
+ self._hidden_params = {
+ "model_id": "fallback-deployment",
+ "additional_headers": {"llm_provider-x-request-id": "req-FALLBACK"},
+ }
+
+ def __iter__(self):
+ return iter([])
+
+ with patch.object(router, "function_with_fallbacks", return_value=FallbackStream()):
+ result = router._completion_streaming_iterator(
+ model_response=FailedStream(),
+ messages=[{"role": "user", "content": "hi"}],
+ initial_kwargs={"model": "gpt-4", "stream": True},
+ )
+ assert result._response_headers == {"x-request-id": "req-FAILED"}
+ for _ in result:
+ pass
+
+ assert result._response_headers == {"x-request-id": "req-FALLBACK"}
+ assert result._hidden_params["model_id"] == "fallback-deployment"
+ assert "only_on_failed_attempt" not in result._hidden_params
+
+
def test_completion_streaming_iterator_fallback_on_429():
"""Sync streaming: MidStreamFallbackError (429 pre-first-chunk) triggers fallback.
From b618c7ad8607dcdd7507cb1d69e980893c96732f Mon Sep 17 00:00:00 2001
From: "devin-ai-integration[bot]"
<158243242+devin-ai-integration[bot]@users.noreply.github.com>
Date: Mon, 7 Sep 2026 13:10:09 -0700
Subject: [PATCH 117/123] fix(proxy): let authorized internal users open vector
store details (#40150)
Co-authored-by: yassin
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
litellm/proxy/_types.py | 1 +
litellm/proxy/vector_store_endpoints/utils.py | 9 ++-
.../proxy/auth/test_route_checks.py | 59 ++++++++++++++++++
.../test_vector_store_access_control.py | 62 +++++++++++++++++++
4 files changed, 128 insertions(+), 3 deletions(-)
diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py
index d79b753b5ff..abce10690e5 100644
--- a/litellm/proxy/_types.py
+++ b/litellm/proxy/_types.py
@@ -848,6 +848,7 @@ class LiteLLMRoutes(enum.Enum):
"/model/{model_id}/update",
"/prompt/list",
"/prompt/info",
+ "/vector_store/info",
# Project read routes - endpoint scopes results to caller's teams (non-admin)
"/project/list",
"/project/info",
diff --git a/litellm/proxy/vector_store_endpoints/utils.py b/litellm/proxy/vector_store_endpoints/utils.py
index f2070e6604c..8363aaee99a 100644
--- a/litellm/proxy/vector_store_endpoints/utils.py
+++ b/litellm/proxy/vector_store_endpoints/utils.py
@@ -161,6 +161,8 @@ async def can_user_access_vector_store(
this vector store id.
5. The caller's team_id matches the vector store's team_id.
+ A dashboard session credential is evaluated against the same effective
+ contexts as listing (its own grants plus each real team of the user).
Otherwise access is denied.
"""
if _is_proxy_admin(user_api_key_dict):
@@ -169,7 +171,8 @@ async def can_user_access_vector_store(
if vector_store.get("team_id") is None:
return True
- return await _is_vector_store_granted(vector_store, user_api_key_dict)
+ auth_contexts: Final = await _vector_store_auth_contexts(user_api_key_dict)
+ return await _is_vector_store_granted_to_any(vector_store, auth_contexts)
async def _is_vector_store_granted(
@@ -219,7 +222,7 @@ async def _team_auth_context(team_id: str, user_api_key_dict: UserAPIKeyAuth) ->
)
-async def _vector_store_listing_auth_contexts(
+async def _vector_store_auth_contexts(
user_api_key_dict: UserAPIKeyAuth,
) -> tuple[UserAPIKeyAuth, ...]:
if not is_ui_session_credential(user_api_key_dict):
@@ -250,7 +253,7 @@ async def filter_listable_vector_stores(
if _is_proxy_admin(user_api_key_dict):
return tuple(vector_stores)
- auth_contexts: Final = await _vector_store_listing_auth_contexts(user_api_key_dict)
+ auth_contexts: Final = await _vector_store_auth_contexts(user_api_key_dict)
return tuple([vs for vs in vector_stores if await _is_vector_store_granted_to_any(vs, auth_contexts)])
diff --git a/tests/test_litellm/proxy/auth/test_route_checks.py b/tests/test_litellm/proxy/auth/test_route_checks.py
index 48926cb7bc2..5b15d4a7d5e 100644
--- a/tests/test_litellm/proxy/auth/test_route_checks.py
+++ b/tests/test_litellm/proxy/auth/test_route_checks.py
@@ -3217,6 +3217,65 @@ def test_internal_user_blocked_from_search_tool_writes(route):
assert "Your role=internal_user" in str(exc_info.value)
+@pytest.mark.parametrize(
+ "user_role",
+ [
+ LitellmUserRoles.INTERNAL_USER.value,
+ LitellmUserRoles.INTERNAL_USER_VIEW_ONLY.value,
+ ],
+)
+def test_non_admin_can_open_vector_store_details(user_role):
+ """Regression for LIT-7132: the dashboard lists a vector store via /vector_store/list
+ (an LLM API route) but opened it via /vector_store/info, which no non-admin allowlist
+ granted, so the route gate 401'd before the handler's per-store access check ran."""
+ user_obj = LiteLLM_UserTable(
+ user_id="test_user",
+ user_email="user@example.com",
+ user_role=user_role,
+ )
+ valid_token = UserAPIKeyAuth(user_id="test_user", user_role=user_role)
+ request = MagicMock(spec=Request)
+ request.query_params = {}
+
+ granted = RouteChecks.non_proxy_admin_allowed_routes_check(
+ user_obj=user_obj,
+ _user_role=user_role,
+ route="/vector_store/info",
+ request=request,
+ valid_token=valid_token,
+ request_data={},
+ )
+ assert granted is None
+
+
+@pytest.mark.parametrize(
+ "route",
+ ["/vector_store/new", "/vector_store/update", "/vector_store/delete"],
+)
+def test_internal_user_blocked_from_vector_store_writes(route):
+ user_obj = LiteLLM_UserTable(
+ user_id="test_user",
+ user_email="user@example.com",
+ user_role=LitellmUserRoles.INTERNAL_USER.value,
+ )
+ valid_token = UserAPIKeyAuth(
+ user_id="test_user",
+ user_role=LitellmUserRoles.INTERNAL_USER.value,
+ )
+ request = MagicMock(spec=Request)
+ request.query_params = {}
+
+ with pytest.raises(Exception, match="Only proxy admin can be used to generate, delete, update"):
+ RouteChecks.non_proxy_admin_allowed_routes_check(
+ user_obj=user_obj,
+ _user_role=LitellmUserRoles.INTERNAL_USER.value,
+ route=route,
+ request=request,
+ valid_token=valid_token,
+ request_data={},
+ )
+
+
def test_proxy_admin_viewer_can_read_another_users_info():
"""Admin Viewer has read parity with Proxy Admin, so the /user/info
key-ownership gate must not apply to it — the Users page reads every row."""
diff --git a/tests/test_litellm/proxy/vector_store_endpoints/test_vector_store_access_control.py b/tests/test_litellm/proxy/vector_store_endpoints/test_vector_store_access_control.py
index 93049b21460..dc4f2900038 100644
--- a/tests/test_litellm/proxy/vector_store_endpoints/test_vector_store_access_control.py
+++ b/tests/test_litellm/proxy/vector_store_endpoints/test_vector_store_access_control.py
@@ -244,3 +244,65 @@ async def test_list_vector_stores_dashboard_session_resolves_real_teams(
),
):
assert await _listed_ids(alice) == expected
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ ("user_team_ids", "expected_status"),
+ [
+ (["team_a"], 200),
+ (["team_b"], 403),
+ ([], 403),
+ ],
+)
+async def test_get_vector_store_info_dashboard_session_resolves_real_teams(
+ user_team_ids: list[str], expected_status: int
+):
+ """Regression for LIT-7132: /vector_store/info must grant a dashboard session the same team-owned stores
+ /vector_store/list shows it, instead of judging the session's reserved litellm-dashboard team id."""
+ from litellm.models.team import LiteLLM_TeamTableCachedObj
+ from litellm.proxy.vector_store_endpoints.management_endpoints import (
+ get_vector_store_info,
+ )
+ from litellm.types.vector_stores import VectorStoreInfoRequest
+
+ alice = UserAPIKeyAuth(
+ team_id="litellm-dashboard",
+ user_id="alice",
+ user_role=LitellmUserRoles.INTERNAL_USER,
+ )
+
+ async def fake_get_team_object(team_id: str, **_kwargs: object) -> LiteLLM_TeamTableCachedObj:
+ return LiteLLM_TeamTableCachedObj(team_id=team_id)
+
+ mock_prisma = MagicMock()
+ mock_prisma.db.litellm_managedvectorstorestable.find_unique = AsyncMock(
+ return_value=MagicMock(model_dump=lambda: dict(_TEAM_A_OWNED))
+ )
+
+ async def outcome() -> int:
+ try:
+ response = await get_vector_store_info(
+ data=VectorStoreInfoRequest(vector_store_id="vs_team_a"), user_api_key_dict=alice
+ )
+ except HTTPException as exc:
+ return exc.status_code
+ assert response["vector_store"]["vector_store_id"] == "vs_team_a"
+ return 200
+
+ with (
+ patch( # test-quality-ok: the endpoint reads the store row through the module-level prisma client, no injection seam
+ "litellm.proxy.proxy_server.prisma_client", mock_prisma
+ ),
+ patch( # test-quality-ok: the endpoint consults the module-level registry before the DB, no injection seam
+ "litellm.vector_store_registry", None
+ ),
+ patch( # test-quality-ok: team rows come from the module-level prisma client, no injection seam
+ "litellm.proxy.auth.auth_checks.get_team_object", new=fake_get_team_object
+ ),
+ patch( # test-quality-ok: the user row comes from the module-level prisma client, no injection seam
+ "litellm.proxy.vector_store_endpoints.utils.resolve_ui_session_team_ids",
+ new=AsyncMock(return_value=user_team_ids),
+ ),
+ ):
+ assert await outcome() == expected_status
From 7c1745cc7178df28434ddd39b4e6861f64826331 Mon Sep 17 00:00:00 2001
From: tin-berri
Date: Mon, 7 Sep 2026 13:55:27 -0700
Subject: [PATCH 118/123] feat(ui): make automatic auto-router setup
discoverable and show what it configured (#40146)
* fix(ui): expand Detailed Configuration after automatic auto-router setup
* feat(ui): promote automatic auto-router setup to a callout banner
* test(ui): read tier chips through testing-library queries to stay in lint budget
* style(ui): drop explanatory comments per repo convention
* test(ui): reject unexpected automatic tier models
---
.../add_model/add_auto_router_tab.test.tsx | 43 +++++++++++++++----
.../add_model/add_auto_router_tab.tsx | 23 +++++-----
2 files changed, 45 insertions(+), 21 deletions(-)
diff --git a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.test.tsx b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.test.tsx
index 5605a993ded..014854ac712 100644
--- a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.test.tsx
+++ b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.test.tsx
@@ -60,6 +60,20 @@ const optionByLabel = (label: string): HTMLElement | undefined =>
const isOptionDisabled = (option: HTMLElement): boolean => option.getAttribute("aria-disabled") === "true";
+const tierChips = (tier: string): HTMLElement => {
+ const placeholder = `Select model(s) for ${tier.toLowerCase()} queries`;
+ const chips = screen
+ .getAllByRole("toolbar")
+ .find((candidate) => within(candidate).queryByLabelText(placeholder) !== null);
+ if (!chips) throw new Error(`No tier row found for "${tier}"`);
+ return chips;
+};
+
+const expectTierModel = (tier: string, model: string): void => {
+ const chips = within(tierChips(tier)).getAllByLabelText(/.+/, { selector: '[data-slot="combobox-chip"]' });
+ expect(chips.map((chip) => chip.getAttribute("aria-label"))).toEqual([model]);
+};
+
const selectTemplate = async (label: string): Promise => {
await userEvent.click(optionByLabel(label)!);
};
@@ -188,11 +202,10 @@ describe("AddAutoRouterTab", () => {
const button = await screen.findByTestId("configure-automatically-button");
await userEvent.click(button);
- expect(
- screen.getByText(
- /Simple: gpt-5.6-luna.*Medium: claude-sonnet-5.*Complex: claude-opus-5.*Reasoning: claude-opus-5/,
- ),
- ).toBeInTheDocument();
+ expectTierModel("Simple", "gpt-5.6-luna");
+ expectTierModel("Medium", "claude-sonnet-5");
+ expectTierModel("Complex", "claude-opus-5");
+ expectTierModel("Reasoning", "claude-opus-5");
expect(toast.success).not.toHaveBeenCalledWith(expect.stringContaining("Configured with"));
});
@@ -209,9 +222,23 @@ describe("AddAutoRouterTab", () => {
const button = await screen.findByTestId("configure-automatically-button");
await userEvent.click(button);
- expect(
- screen.getByText(/Simple: gpt-5.6-luna.*Medium: claude-sonnet-5.*Complex: gpt-5.6-sol.*Reasoning: gpt-5.6-sol/),
- ).toBeInTheDocument();
+ expectTierModel("Simple", "gpt-5.6-luna");
+ expectTierModel("Medium", "claude-sonnet-5");
+ expectTierModel("Complex", "gpt-5.6-sol");
+ expectTierModel("Reasoning", "gpt-5.6-sol");
+ });
+
+ it("opens Detailed Configuration on the tiers automatic setup just filled in", async () => {
+ const simpleModel = "gpt-5.6-luna";
+ mockFetchAvailableModels.mockResolvedValue([...ALL_FAMILY_MODELS, { model_group: simpleModel, mode: "chat" }]);
+ renderWithProviders( );
+
+ expect(screen.queryByText("Complexity Tier Configuration")).not.toBeInTheDocument();
+
+ await userEvent.click(await screen.findByTestId("configure-automatically-button"));
+
+ expect(screen.getByText("Complexity Tier Configuration")).toBeInTheDocument();
+ expectTierModel("Simple", simpleModel);
});
// Nothing is filled in, so there is nothing to submit. The button reports that itself instead of
diff --git a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx
index 60780f9cbe0..2d0d6bc2fd8 100644
--- a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx
+++ b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx
@@ -207,10 +207,6 @@ const AddAutoRouterTab: React.FC = ({
const [isSubmitting, setIsSubmitting] = useState(false);
const [selectedPreset, setSelectedPreset] = useState(undefined);
- // Closed by default: a caller opens it deliberately, either by clicking it or by choosing Custom
- // (which expands it automatically, since there's nothing else to show them their config from). A
- // preset re-collapses it after prefilling, offering the same "here's what got filled in, expand to
- // change it" affordance. A caller can always toggle it manually at any point.
const [detailsExpanded, setDetailsExpanded] = useState(false);
const [isRoutingTestVisible, setIsRoutingTestVisible] = useState(false);
@@ -335,7 +331,7 @@ const AddAutoRouterTab: React.FC = ({
if (automaticRouterConfig === null) return;
setSelectedPreset(undefined);
applyPrefill({ ...buildEmptyPrefill(), complexityRouterConfig: automaticRouterConfig });
- setDetailsExpanded(false);
+ setDetailsExpanded(true);
toast.success("Automatic setup created", { description: tierConfigSummary(automaticRouterConfig) });
};
@@ -543,14 +539,15 @@ const AddAutoRouterTab: React.FC = ({
{!automaticSetupLoading && automaticRouterConfig && (
-
- Configure automatically
-
+
+
+
Not sure where to start?
+
Let us pick models for each complexity tier.
+
+
+ Configure automatically
+
+
)}
From f896df1b065e2627e3f018e7f504c44666f5d35c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jean-R=C3=A9mi=20Larcelet-Prost?=
Date: Mon, 7 Sep 2026 23:19:46 +0200
Subject: [PATCH 119/123] docs: fix stale file paths in ARCHITECTURE.md
(#40157)
Several file references under proxy/management_helpers/ and other
paths no longer exist; the code moved to proxy/common_utils/,
proxy/db/db_transaction_queue/, litellm_enterprise/proxy/common_utils/,
proxy/hooks/litellm_skills/, and litellm_core_utils/.
---
ARCHITECTURE.md | 16 ++++++++--------
1 file changed, 8 insertions(+), 8 deletions(-)
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 3d2fa3e51c8..b04e004aa1a 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -149,7 +149,7 @@ graph TD
| `parallel_request_limiter` | `proxy/hooks/parallel_request_limiter_v3.py` | Rate limiting per key/user |
| `cache_control_check` | `proxy/hooks/cache_control_check.py` | Cache validation |
| `responses_id_security` | `proxy/hooks/responses_id_security.py` | Response ID validation |
-| `litellm_skills` | `proxy/hooks/skills_injection.py` | Skills injection |
+| `litellm_skills` | `proxy/hooks/litellm_skills/main.py` | Skills injection |
To add a new proxy hook, implement `CustomLogger` and register in `PROXY_HOOKS`.
@@ -220,20 +220,20 @@ graph LR
| Job | Interval | Purpose | Key Files |
|-----|----------|---------|-----------|
| `update_spend` | 60s | Batch write spend logs to PostgreSQL | `proxy/db/db_spend_update_writer.py` |
-| `reset_budget` | 10-12min | Reset budgets for keys/users/teams | `proxy/management_helpers/budget_reset_job.py` |
+| `reset_budget` | 10-12min | Reset budgets for keys/users/teams | `proxy/common_utils/reset_budget_job.py` |
| `add_deployment` | 10s | Sync new model deployments from DB | `proxy/proxy_server.py` (`ProxyConfig`) |
-| `cleanup_old_spend_logs` | cron/interval | Delete old spend logs | `proxy/management_helpers/spend_log_cleanup.py` |
-| `check_batch_cost` | 30min | Calculate costs for batch jobs | `proxy/management_helpers/check_batch_cost_job.py` |
-| `check_responses_cost` | 30min | Calculate costs for responses API | `proxy/management_helpers/check_responses_cost_job.py` |
-| `process_rotations` | 1hr | Auto-rotate API keys | `proxy/management_helpers/key_rotation_manager.py` |
+| `cleanup_old_spend_logs` | cron/interval | Delete old spend logs | `proxy/db/db_transaction_queue/spend_log_cleanup.py` |
+| `check_batch_cost` | 30min | Calculate costs for batch jobs | `enterprise/litellm_enterprise/proxy/common_utils/check_batch_cost.py` |
+| `check_responses_cost` | 30min | Calculate costs for responses API | `enterprise/litellm_enterprise/proxy/common_utils/check_responses_cost.py` |
+| `process_rotations` | 1hr | Auto-rotate API keys | `proxy/common_utils/key_rotation_manager.py` |
| `_run_background_health_check` | continuous | Health check model deployments | `proxy/proxy_server.py` |
| `send_weekly_spend_report` | weekly | Slack spend alerts | `proxy/utils.py` (`SlackAlerting`) |
| `send_monthly_spend_report` | monthly | Slack spend alerts | `proxy/utils.py` (`SlackAlerting`) |
**Cost Attribution Flow:**
1. LLM response returns to `utils.py` wrapper after `litellm.acompletion()` completes
-2. `update_response_metadata()` (`llm_response_utils/response_metadata.py`) is called
-3. `logging_obj._response_cost_calculator()` (`litellm_logging.py`) calculates cost via `litellm.completion_cost()` (`cost_calculator.py`)
+2. `update_response_metadata()` (`litellm_core_utils/llm_response_utils/response_metadata.py`) is called
+3. `logging_obj._response_cost_calculator()` (`litellm_core_utils/litellm_logging.py`) calculates cost via `litellm.completion_cost()` (`cost_calculator.py`)
4. Cost is stored in `response._hidden_params["response_cost"]`
5. `proxy/common_request_processing.py` extracts cost from `hidden_params` and adds to response headers (`x-litellm-response-cost`)
6. `logging_obj.async_success_handler()` triggers callbacks including `_ProxyDBLogger.async_log_success_event()`
From 038025ba5e2a6796186a86909de03e1a5eeb915d Mon Sep 17 00:00:00 2001
From: "devin-ai-integration[bot]"
<158243242+devin-ai-integration[bot]@users.noreply.github.com>
Date: Mon, 7 Sep 2026 14:33:02 -0700
Subject: [PATCH 120/123] fix(guardrails): accept on_violation block and alert
for mcp_security (#40155)
* fix(guardrails): accept on_violation block and alert for mcp_security
The MCP Security policy template sends on_violation: "block", but the shared
LitellmParams model only allowed the /v1/realtime values "warn" and
"end_session", so POST /guardrails returned 422 before the MCP guardrail was
initialized. Widen the literal to include the MCP actions, map every non-alert
value to MCP's default "block" at init, and regenerate the lazy OpenAPI
snapshot and dashboard API types
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* fix(guardrails): restrict on_violation block/alert to mcp_security and keep legacy MCP mapping
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* fix(guardrails): return 422 when PATCH sets an mcp_security-only on_violation on another guardrail
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---------
Co-authored-by: yassin
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
litellm/proxy/_lazy_openapi_snapshot.json | 12 +++++---
.../proxy/guardrails/guardrail_endpoints.py | 10 +++++--
.../guardrail_hooks/mcp_security/__init__.py | 7 ++---
litellm/types/guardrails.py | 20 +++++++++++--
.../guardrail_hooks/test_mcp_security.py | 25 +++++++++++++++-
.../guardrails/test_guardrail_endpoints.py | 16 ++++++++++
.../test_guardrails_case_normalization.py | 30 +++++++++++++++++++
ui/litellm-dashboard/src/lib/http/schema.d.ts | 8 ++---
8 files changed, 110 insertions(+), 18 deletions(-)
diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json
index 4093f2c5248..c71761a7adb 100644
--- a/litellm/proxy/_lazy_openapi_snapshot.json
+++ b/litellm/proxy/_lazy_openapi_snapshot.json
@@ -9652,7 +9652,9 @@
{
"enum": [
"warn",
- "end_session"
+ "end_session",
+ "block",
+ "alert"
],
"type": "string"
},
@@ -9660,7 +9662,7 @@
"type": "null"
}
],
- "description": "For /v1/realtime sessions: 'warn' speaks the violation message and continues; 'end_session' speaks the message and closes the connection.",
+ "description": "For /v1/realtime sessions: 'warn' speaks the violation message and continues; 'end_session' speaks the message and closes the connection. For guardrail='mcp_security': 'block' rejects the request; 'alert' only logs a warning.",
"title": "On Violation"
},
"only_scan_new_messages": {
@@ -11969,7 +11971,9 @@
{
"enum": [
"warn",
- "end_session"
+ "end_session",
+ "block",
+ "alert"
],
"type": "string"
},
@@ -11977,7 +11981,7 @@
"type": "null"
}
],
- "description": "For /v1/realtime sessions: 'warn' speaks the violation message and continues; 'end_session' speaks the message and closes the connection.",
+ "description": "For /v1/realtime sessions: 'warn' speaks the violation message and continues; 'end_session' speaks the message and closes the connection. For guardrail='mcp_security': 'block' rejects the request; 'alert' only logs a warning.",
"title": "On Violation"
},
"only_scan_new_messages": {
diff --git a/litellm/proxy/guardrails/guardrail_endpoints.py b/litellm/proxy/guardrails/guardrail_endpoints.py
index 744b2959c73..afb9997f2e6 100644
--- a/litellm/proxy/guardrails/guardrail_endpoints.py
+++ b/litellm/proxy/guardrails/guardrail_endpoints.py
@@ -13,7 +13,7 @@ from typing import TYPE_CHECKING, Any, Final, Literal, Protocol, TypeVar, Union,
from urllib.parse import urlparse
from fastapi import APIRouter, Depends, HTTPException, Request
-from pydantic import BaseModel
+from pydantic import BaseModel, ValidationError
from litellm._logging import verbose_proxy_logger
from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH
@@ -1202,7 +1202,13 @@ async def patch_guardrail(
litellm_params_dict: Final = litellm_params.model_dump(exclude_unset=True)
litellm_params_dict.update(requested_litellm_params)
merged_litellm_params: Final = _as_str_object_mapping(litellm_params_dict)
- litellm_params = LitellmParams(**merged_litellm_params)
+ try:
+ litellm_params = LitellmParams(**merged_litellm_params)
+ except ValidationError as validation_error:
+ raise HTTPException(
+ status_code=422,
+ detail=f"Invalid guardrail configuration, update rejected: {validation_error}",
+ ) from validation_error
# Update guardrail_info if provided
guardrail_info: Final = (
diff --git a/litellm/proxy/guardrails/guardrail_hooks/mcp_security/__init__.py b/litellm/proxy/guardrails/guardrail_hooks/mcp_security/__init__.py
index d53a4157e0e..1607dfff63e 100644
--- a/litellm/proxy/guardrails/guardrail_hooks/mcp_security/__init__.py
+++ b/litellm/proxy/guardrails/guardrail_hooks/mcp_security/__init__.py
@@ -1,4 +1,4 @@
-from typing import TYPE_CHECKING, Final, Literal, Optional, cast
+from typing import TYPE_CHECKING, Final, Literal, Optional
import litellm
from litellm.proxy.guardrails.guardrail_hooks.mcp_security.mcp_security_guardrail import (
@@ -20,10 +20,7 @@ def initialize_guardrail(
if not guardrail_name:
raise ValueError("MCP Security: guardrail_name is required")
- on_violation: Final[Literal["block", "alert"]] = cast(
- Literal["block", "alert"],
- getattr(litellm_params, "on_violation", "block"),
- )
+ on_violation: Final[Literal["block", "alert"]] = "block" if litellm_params.on_violation == "block" else "alert"
mcp_security_guardrail: Final = MCPSecurityGuardrail(
guardrail_name=guardrail_name,
diff --git a/litellm/types/guardrails.py b/litellm/types/guardrails.py
index ef28181eba5..02dee40f2a3 100644
--- a/litellm/types/guardrails.py
+++ b/litellm/types/guardrails.py
@@ -778,6 +778,9 @@ class ContentFilterConfigModel(BaseModel):
)
+MCP_SECURITY_ON_VIOLATION: Final = frozenset({"block", "alert"})
+
+
class BaseLitellmParams(ContentFilterConfigModel): # works for new and patch update guardrails
api_key: str | None = Field(default=None, description="API key for the guardrail service")
api_base: str | None = Field(default=None, description="Base URL for the guardrail service API")
@@ -886,9 +889,13 @@ class BaseLitellmParams(ContentFilterConfigModel): # works for new and patch up
default=None,
description="For /v1/realtime sessions: automatically close the session after this many guardrail violations.",
)
- on_violation: Literal["warn", "end_session"] | None = Field(
+ on_violation: Literal["warn", "end_session", "block", "alert"] | None = Field(
default=None,
- description="For /v1/realtime sessions: 'warn' speaks the violation message and continues; 'end_session' speaks the message and closes the connection.",
+ description=(
+ "For /v1/realtime sessions: 'warn' speaks the violation message and continues; "
+ "'end_session' speaks the message and closes the connection. "
+ "For guardrail='mcp_security': 'block' rejects the request; 'alert' only logs a warning."
+ ),
)
realtime_violation_message: str | None = Field(
default=None,
@@ -1093,6 +1100,15 @@ class LitellmParams( # pyright: ignore[reportIncompatibleVariableOverride] # o
except (TypeError, ValueError) as e:
raise ValueError(f"timeout must be numeric, got {v!r}") from e
+ @model_validator(mode="after")
+ def validate_on_violation_for_guardrail(self) -> "LitellmParams":
+ if (
+ self.on_violation in MCP_SECURITY_ON_VIOLATION
+ and self.guardrail != SupportedGuardrailIntegrations.MCP_SECURITY.value
+ ):
+ raise ValueError(f"on_violation={self.on_violation!r} is only supported by guardrail='mcp_security'")
+ return self
+
def __init__(self, **kwargs) -> None:
default_on: Final = kwargs.pop("default_on", None)
if default_on is not None:
diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_mcp_security.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_mcp_security.py
index 4444cd693ff..d57a91d45bf 100644
--- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_mcp_security.py
+++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_mcp_security.py
@@ -6,16 +6,19 @@ and allows requests with only registered servers. Covers both /chat/completions
and /responses API paths (same pre_call_hook logic, different call_type).
"""
+from typing import Literal
from unittest.mock import MagicMock, patch
import pytest
from fastapi import HTTPException
+import litellm
from litellm.proxy._types import UserAPIKeyAuth
+from litellm.proxy.guardrails.guardrail_hooks.mcp_security import initialize_guardrail
from litellm.proxy.guardrails.guardrail_hooks.mcp_security.mcp_security_guardrail import (
MCPSecurityGuardrail,
)
-from litellm.types.guardrails import GuardrailEventHooks
+from litellm.types.guardrails import Guardrail, GuardrailEventHooks, LitellmParams
@pytest.fixture
@@ -182,3 +185,23 @@ class TestMCPSecurityGuardrailPreCall:
call_type="acompletion",
)
assert result == data
+
+
+class TestInitializeGuardrail:
+ @pytest.mark.parametrize(
+ "configured,expected",
+ [("block", "block"), ("alert", "alert"), (None, "alert"), ("warn", "alert"), ("end_session", "alert")],
+ )
+ def test_on_violation_from_litellm_params(
+ self,
+ configured: Literal["block", "alert", "warn", "end_session"] | None,
+ expected: Literal["block", "alert"],
+ ):
+ litellm_params = LitellmParams(guardrail="mcp_security", mode="pre_call", on_violation=configured)
+ guardrail = Guardrail(guardrail_name="mcp-security-block", litellm_params=litellm_params)
+
+ result = initialize_guardrail(litellm_params=litellm_params, guardrail=guardrail)
+
+ assert isinstance(result, MCPSecurityGuardrail)
+ assert result.on_violation == expected
+ assert result in litellm.callbacks
diff --git a/tests/test_litellm/proxy/guardrails/test_guardrail_endpoints.py b/tests/test_litellm/proxy/guardrails/test_guardrail_endpoints.py
index fe2cd819717..530f8ffd854 100644
--- a/tests/test_litellm/proxy/guardrails/test_guardrail_endpoints.py
+++ b/tests/test_litellm/proxy/guardrails/test_guardrail_endpoints.py
@@ -1361,6 +1361,22 @@ async def test_patch_guardrail_endpoint(
assert "Failed to update" in str(mock_logger.warning.call_args)
+@pytest.mark.asyncio
+async def test_patch_guardrail_rejects_mcp_only_on_violation_with_422(mocker, mock_guardrail_registry):
+ mocker.patch("litellm.proxy.proxy_server.prisma_client", mocker.Mock()) # test-quality-ok: endpoint has no DI seam
+ mocker.patch( # test-quality-ok: endpoint has no DI seam
+ "litellm.proxy.guardrails.guardrail_endpoints.GUARDRAIL_REGISTRY", mock_guardrail_registry
+ )
+ request = PatchGuardrailRequest(litellm_params=BaseLitellmParams(on_violation="block"))
+
+ with pytest.raises(HTTPException) as exc_info:
+ await patch_guardrail("test-guardrail-id", request, user_api_key_dict=MOCK_ADMIN_USER)
+
+ assert exc_info.value.status_code == 422
+ assert "only supported by guardrail='mcp_security'" in str(exc_info.value.detail)
+ mock_guardrail_registry.update_guardrail_in_db.assert_not_called()
+
+
@pytest.mark.parametrize(
"scenario,expected_result,expected_exception",
[
diff --git a/tests/test_litellm/types/test_guardrails_case_normalization.py b/tests/test_litellm/types/test_guardrails_case_normalization.py
index 3e7a573ea8e..26c1d395320 100644
--- a/tests/test_litellm/types/test_guardrails_case_normalization.py
+++ b/tests/test_litellm/types/test_guardrails_case_normalization.py
@@ -2,6 +2,8 @@
Test case normalization in LitellmParams for all guardrail types
"""
+from typing import Literal
+
import pytest
from pydantic import ValidationError
@@ -93,6 +95,34 @@ class TestLitellmParamsCaseNormalization:
assert params.on_disallowed_action.islower()
+class TestOnViolationAcceptedValues:
+ """on_violation is shared by /v1/realtime guardrails and the mcp_security guardrail"""
+
+ @pytest.mark.parametrize("action", ["block", "alert"])
+ def test_mcp_security_policy_template_on_violation_is_accepted(self, action: Literal["block", "alert"]):
+ params = LitellmParams(
+ guardrail="mcp_security",
+ mode="pre_call",
+ default_on=True,
+ on_violation=action,
+ )
+ assert params.on_violation == action
+
+ @pytest.mark.parametrize("action", ["warn", "end_session"])
+ def test_realtime_on_violation_still_accepted(self, action: Literal["warn", "end_session"]):
+ params = LitellmParams(guardrail="presidio", mode="pre_call", on_violation=action)
+ assert params.on_violation == action
+
+ @pytest.mark.parametrize("action", ["block", "alert"])
+ def test_mcp_only_on_violation_is_rejected_for_other_guardrails(self, action: Literal["block", "alert"]):
+ with pytest.raises(ValidationError, match="only supported by guardrail='mcp_security'"):
+ LitellmParams(guardrail="presidio", mode="pre_call", on_violation=action)
+
+ def test_unknown_on_violation_is_rejected(self):
+ with pytest.raises(ValidationError):
+ LitellmParams(guardrail="mcp_security", mode="pre_call", on_violation="ignore")
+
+
class TestSensitiveDataRoutingValidation:
"""on_sensitive_data='route' requires a target model to be set"""
diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts
index 9de988fde4c..6fb08445aff 100644
--- a/ui/litellm-dashboard/src/lib/http/schema.d.ts
+++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts
@@ -23759,9 +23759,9 @@ export interface components {
on_sensitive_data?: ("block" | "route") | null;
/**
* On Violation
- * @description For /v1/realtime sessions: 'warn' speaks the violation message and continues; 'end_session' speaks the message and closes the connection.
+ * @description For /v1/realtime sessions: 'warn' speaks the violation message and continues; 'end_session' speaks the message and closes the connection. For guardrail='mcp_security': 'block' rejects the request; 'alert' only logs a warning.
*/
- on_violation?: ("warn" | "end_session") | null;
+ on_violation?: ("warn" | "end_session" | "block" | "alert") | null;
/**
* Only Scan New Messages
* @description When True, the guardrail only scans messages that have not already been scanned earlier in the same session (identified by litellm_session_id / session_id). Message content is hashed per session and cached; only the diff (new or edited messages) is sent to the guardrail provider on follow-up calls. Falls back to a full scan when the request has no session id or the cache is unavailable. Intended for blocking/detection guardrails; not applied when mask_request_content is set.
@@ -30801,9 +30801,9 @@ export interface components {
on_sensitive_data?: ("block" | "route") | null;
/**
* On Violation
- * @description For /v1/realtime sessions: 'warn' speaks the violation message and continues; 'end_session' speaks the message and closes the connection.
+ * @description For /v1/realtime sessions: 'warn' speaks the violation message and continues; 'end_session' speaks the message and closes the connection. For guardrail='mcp_security': 'block' rejects the request; 'alert' only logs a warning.
*/
- on_violation?: ("warn" | "end_session") | null;
+ on_violation?: ("warn" | "end_session" | "block" | "alert") | null;
/**
* Only Scan New Messages
* @description When True, the guardrail only scans messages that have not already been scanned earlier in the same session (identified by litellm_session_id / session_id). Message content is hashed per session and cached; only the diff (new or edited messages) is sent to the guardrail provider on follow-up calls. Falls back to a full scan when the request has no session id or the cache is unavailable. Intended for blocking/detection guardrails; not applied when mask_request_content is set.
From c5ec2eedc14fa68707d6cf4d77dcfe57b6b33ba5 Mon Sep 17 00:00:00 2001
From: tin-berri
Date: Mon, 7 Sep 2026 14:41:05 -0700
Subject: [PATCH 121/123] fix(spend): price caching savings on the billed
request basis (#40160)
Resolves LIT-7137
Co-authored-by: Claude Code
---
.../litellm_core_utils/llm_cost_calc/utils.py | 86 ++++++++---
litellm/proxy/db/db_spend_update_writer.py | 2 +
litellm/proxy/spend_tracking/savings.py | 105 +++++--------
.../llm_cost_calc/test_llm_cost_calc_utils.py | 38 +++++
.../proxy/spend_tracking/test_savings.py | 143 +++++++++++++++++-
5 files changed, 288 insertions(+), 86 deletions(-)
diff --git a/litellm/litellm_core_utils/llm_cost_calc/utils.py b/litellm/litellm_core_utils/llm_cost_calc/utils.py
index 9432fefc368..68dc27ec25e 100644
--- a/litellm/litellm_core_utils/llm_cost_calc/utils.py
+++ b/litellm/litellm_core_utils/llm_cost_calc/utils.py
@@ -514,6 +514,7 @@ def _get_token_base_cost(
current_time: datetime | None = None,
*,
threshold_is_inclusive: bool = False,
+ missing_cache_read_uses_input: bool = False,
) -> tuple[float, float, float, float, float]:
"""
Return prompt cost, completion cost, and cache costs for a given model and usage.
@@ -524,6 +525,9 @@ def _get_token_base_cost(
`threshold_is_inclusive` switches that comparison to >=, for providers such as xAI
that bill the higher tier once the prompt reaches the threshold.
+ `missing_cache_read_uses_input` resolves an absent cache-read rate to the resolved
+ input rate instead of 0.0; an explicit 0.0 rate stays a real price either way.
+
Returns:
Tuple[float, float, float, float] - (prompt_cost, completion_cost, cache_creation_cost, cache_read_cost)
"""
@@ -551,29 +555,16 @@ def _get_token_base_cost(
float,
_get_cost_per_unit(model_info, "cache_creation_input_token_cost_above_1hr"),
)
- cache_read_cost = cast(float, _get_cost_per_unit(model_info, cache_read_cost_key))
+ cache_read_cost = _get_cost_per_unit(model_info, cache_read_cost_key, default_value=None)
## CHECK IF ABOVE THRESHOLD
# Optimization: collect threshold keys first to avoid sorting all model_info keys.
- # Most models don't have threshold pricing, so we can return early.
# Exclude service_tier-specific variants (e.g. input_cost_per_token_above_200k_tokens_priority)
# so that the threshold detection loop only processes standard keys. The
# service_tier-specific above-threshold key is resolved later via _get_service_tier_cost_key.
threshold_keys: Final = [
k for k in model_info if k.startswith("input_cost_per_token_above_") and not k.endswith(_SERVICE_TIER_SUFFIXES)
]
- if not threshold_keys:
- return _apply_off_peak_to_base_costs(
- model_info,
- current_time,
- (
- prompt_base_cost,
- completion_base_cost,
- cache_creation_cost,
- cache_creation_cost_above_1hr,
- cache_read_cost,
- ),
- )
# Only sort the threshold keys (typically 1-2 keys instead of 66+)
threshold: float | None = None
@@ -662,10 +653,7 @@ def _get_token_base_cost(
),
)
- cache_read_cost = cast(
- float,
- _get_cost_per_unit(model_info, cache_read_tiered_key, cache_read_cost),
- )
+ cache_read_cost = _get_cost_per_unit(model_info, cache_read_tiered_key, cache_read_cost)
break
except (IndexError, ValueError):
@@ -673,6 +661,17 @@ def _get_token_base_cost(
except Exception:
continue
+ if cache_read_cost is None:
+ cache_read_cost = (
+ _off_peak_rate(
+ _open_off_peak_block(model_info, current_time) or MappingProxyType({}),
+ "input_cost_per_token",
+ prompt_base_cost,
+ )
+ if missing_cache_read_uses_input
+ else 0.0
+ )
+
return _apply_off_peak_to_base_costs(
model_info,
current_time,
@@ -1416,6 +1415,57 @@ def get_token_type_cost_breakdown(
)
+def calculate_prompt_caching_savings(
+ model_info: ModelInfo,
+ usage: Usage,
+ custom_llm_provider: str | None,
+ service_tier: str | None = None,
+ data_residency: str | None = None,
+ vertex_location: str | None = None,
+ billed_at: datetime | None = None,
+) -> float:
+ """Read discount minus write premium, using the biller's rate and TTL resolution.
+
+ Missing reads and unpublished (missing/zero) writes claim no saving or premium;
+ explicit zero reads remain free. An unpublished 1h price uses the ordinary write rate.
+ ``billed_at`` is the request's completion time, so off-peak windows resolve as the
+ biller saw them rather than at the later spend write.
+ """
+ prompt_base_cost, _, cache_creation_cost, cache_creation_cost_above_1hr, cache_read_cost = _get_token_base_cost(
+ model_info=model_info,
+ usage=usage,
+ service_tier=service_tier,
+ current_time=billed_at,
+ threshold_is_inclusive=_uses_inclusive_token_thresholds(custom_llm_provider),
+ missing_cache_read_uses_input=True,
+ )
+ write_rate: Final = cache_creation_cost or prompt_base_cost
+ write_rate_1h: Final = cache_creation_cost_above_1hr or write_rate
+ prompt_tokens_details: Final = parse_prompt_tokens_details(usage)
+ cache_read_tokens: Final = max(prompt_tokens_details["cache_hit_tokens"], 0)
+ cache_creation_tokens: Final = max(prompt_tokens_details["cache_creation_tokens"], 0)
+ details: Final = prompt_tokens_details["cache_creation_token_details"]
+ cache_creation_details: Final = (
+ CacheCreationTokenDetails(
+ ephemeral_5m_input_tokens=max(details.ephemeral_5m_input_tokens or 0, 0),
+ ephemeral_1h_input_tokens=max(details.ephemeral_1h_input_tokens or 0, 0),
+ )
+ if details is not None
+ else None
+ )
+ read_discount: Final = cache_read_tokens * max(prompt_base_cost - cache_read_cost, 0.0)
+ write_premium: Final = calculate_cache_writing_cost(
+ cache_creation_tokens=cache_creation_tokens,
+ cache_creation_token_details=cache_creation_details,
+ cache_creation_cost_above_1hr=write_rate_1h - prompt_base_cost,
+ cache_creation_cost=write_rate - prompt_base_cost,
+ )
+ uplift: Final = _get_regional_uplift_multiplier(model_info, data_residency) * get_vertex_regional_endpoint_uplift(
+ model_info, vertex_location
+ )
+ return (read_discount - write_premium) * uplift
+
+
def calculate_image_response_cost_from_usage(
model: str,
image_response: ImageResponse,
diff --git a/litellm/proxy/db/db_spend_update_writer.py b/litellm/proxy/db/db_spend_update_writer.py
index 9230be8055e..914c961b145 100644
--- a/litellm/proxy/db/db_spend_update_writer.py
+++ b/litellm/proxy/db/db_spend_update_writer.py
@@ -502,6 +502,7 @@ class DBSpendUpdateWriter:
llm_router=get_llm_router,
cost_breakdown=metadata.get("cost_breakdown"),
recorded_autorouter_savings=metadata.get("autorouter_savings"),
+ billed_at=payload.get("endTime"),
)
transaction: Final = build_autorouter_turn_transaction(
payload=payload,
@@ -2188,6 +2189,7 @@ class DBSpendUpdateWriter:
usage_object=usage_obj,
cost_breakdown=_metadata.get("cost_breakdown"),
recorded_autorouter_savings=_metadata.get("autorouter_savings"),
+ billed_at=payload.get("endTime"),
)
daily_transaction: Final = BaseDailySpendTransaction(
diff --git a/litellm/proxy/spend_tracking/savings.py b/litellm/proxy/spend_tracking/savings.py
index 1d0eb12da75..c541b9b40e5 100644
--- a/litellm/proxy/spend_tracking/savings.py
+++ b/litellm/proxy/spend_tracking/savings.py
@@ -9,12 +9,17 @@ have been aggregated across models.
"""
from collections.abc import Callable, Mapping
+from datetime import datetime
from typing import TYPE_CHECKING, Final, NamedTuple
import litellm
from litellm._logging import verbose_proxy_logger
from litellm.constants import INTERNAL_CALL_ORIGIN_METADATA_KEY
-from litellm.litellm_core_utils.llm_cost_calc.utils import _get_cost_per_unit, generic_cost_per_token
+from litellm.litellm_core_utils.llm_cost_calc.utils import (
+ _get_cost_per_unit,
+ calculate_prompt_caching_savings,
+ generic_cost_per_token,
+)
from litellm.types.integrations.anthropic_cache_control_hook import (
GATEWAY_INJECTED_CACHE_METADATA_KEY,
GATEWAY_INJECTED_FOR_EVERY_DEPLOYMENT,
@@ -32,42 +37,13 @@ class SavingsSpend(NamedTuple):
gateway_injected_caching: float = 0.0
-def _input_cache_read_and_write_cost(info: ModelInfo | None) -> tuple[float, float, float]:
- """
- Return ``(input_cost, cache_read_cost, cache_write_cost)`` per token.
-
- ``info`` is whatever pricing the caller resolved -- deployment rates when the
- request came through a router deployment, public rates otherwise -- so a
- negotiated price is honoured here rather than silently replaced by the list rate.
- ``None`` falls open to ``(0.0, 0.0, 0.0)`` so savings degrade to zero rather than
- raising inside the spend writer.
-
- Prices are read through ``_get_cost_per_unit``, the same accessor the cost
- calculator uses, which coerces the string prices a ``config.yaml`` can produce
- (``"3e-7"``) and resolves service-tier suffixes.
-
- An absent cache price mirrors the input cost, which yields a zero discount on the
- read leg and a zero premium on the write leg. Mirroring rather than taking
- ``_get_cost_per_unit``'s 0.0 default is load-bearing on the write leg: a zero write
- price would make the premium ``0 - input_cost``, turning a model that simply has no
- write pricing into a spurious extra saving.
-
- The two legs then differ on an explicit ``0.0``, and the asymmetry is deliberate. A
- free cache *write* does not exist -- entries carrying a literal zero (``deepseek-chat``
- does) mean "no separate price", so a falsy write price also mirrors input. A free
- cache *read* is real: 15 models charge for input and serve reads for nothing, which
- is the largest discount available, so the read leg keeps its literal zero.
- """
- if info is None:
- return 0.0, 0.0, 0.0
- input_cost: Final = _get_cost_per_unit(info, "input_cost_per_token") or 0.0
- cache_read_cost: Final = _get_cost_per_unit(info, "cache_read_input_token_cost", default_value=None)
- cache_write_cost: Final = _get_cost_per_unit(info, "cache_creation_input_token_cost", default_value=None)
- return (
- input_cost,
- input_cost if cache_read_cost is None else cache_read_cost,
- cache_write_cost if cache_write_cost else input_cost,
- )
+def _coerce_billed_at(value: datetime | str | None) -> datetime | None:
+ if isinstance(value, datetime) or value is None:
+ return value
+ try:
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
+ except ValueError:
+ return None
class _ModelIdentity(NamedTuple):
@@ -586,6 +562,7 @@ def compute_savings_spend(
llm_router: "Callable[[], Router | None] | None" = None,
cost_breakdown: Mapping[str, object] | None = None,
recorded_autorouter_savings: object = None,
+ billed_at: datetime | str | None = None,
) -> SavingsSpend:
"""
Dollar savings for one request, split by optimization driver.
@@ -595,24 +572,10 @@ def compute_savings_spend(
premium paid to write those entries, both derived here from ``usage_object`` so no
caller can hand in a count that disagrees with the usage record.
- The net form follows from what the request would have cost with caching off. The
- provider reports ``prompt_tokens`` as the inclusive total of three disjoint
- partitions (uncached text, cache reads, cache writes), so an uncached counterfactual
- bills every one of those tokens at the flat input rate::
-
- would_have_cost = (text + reads + writes) * input
- actually_cost = text * input + reads * read_rate + writes * write_rate
- savings = reads * (input - read_rate) - writes * (write_rate - input)
-
- So the write leg subtracts the write PREMIUM, not the whole write cost: those tokens
- had to be sent either way, and the counterfactual already pays the input rate for
- them. The premium stays signed, because a handful of models price writes below their
- input rate and there the write is a genuine extra saving.
-
- A request that only writes cache and gets no hits therefore reports negative savings,
- which is accurate: it really did cost more than the uncached call would have. The
- daily rollup increments arithmetically, so those rows offset positive ones in the
- same bucket.
+ The uncached counterfactual pays the ordinary input rate for the same prompt size
+ and tier. Cache writes subtract only the premium over that rate, split by TTL.
+ Savings stay signed: a write-only request can lose money, and daily rollups net
+ those losses against read savings.
Caching is reported twice. ``prompt_caching`` is every net dollar caching saved,
whoever caused it, which is what a customer means by "what did caching save me".
@@ -638,12 +601,9 @@ def compute_savings_spend(
calls this and only auto-routed ones need one, so looking it up eagerly at the call
site would fetch and discard it on the rest.
- ``cost_breakdown`` is what the cost calculator recorded for this request, and it
- carries both what the request really cost and the tier and region it was priced on.
- Only the auto-router driver reads it. Compression and prompt caching price a
- hypothetical token delta off flat rate keys, so they are blind to tiered pricing in
- the same way; that is pre-existing behaviour on two shipped drivers rather than
- something introduced here, and moving those numbers is its own change.
+ ``cost_breakdown`` supplies the biller's tier and region to caching and auto-router
+ savings. Caching also uses the logged prompt size and TTL split. Compression retains
+ its flat input-rate estimate; changing that counterfactual is a separate concern.
``recorded_autorouter_savings`` is the figure the logging path stamped on the spend
log's metadata, honoured over recomputation so the rollup, the turn table and the
@@ -658,13 +618,24 @@ def compute_savings_spend(
pricing: Final = _effective_model_info(router_instance, model_id, model or "") or (
_model_info(identity) if identity else None
)
- input_cost, cache_read_cost, cache_write_cost = _input_cache_read_and_write_cost(pricing)
+ input_cost: Final = (_get_cost_per_unit(pricing, "input_cost_per_token") or 0.0) if pricing else 0.0
compression: Final = max(compression_saved_tokens, 0) * input_cost
- cache_read_input_tokens: Final = extract_cache_read_tokens(usage_object)
- cache_creation_input_tokens: Final = extract_cache_creation_tokens(usage_object)
- read_discount: Final = max(cache_read_input_tokens, 0) * max(input_cost - cache_read_cost, 0.0)
- write_premium: Final = max(cache_creation_input_tokens, 0) * (cache_write_cost - input_cost)
- prompt_caching: Final = read_discount - write_premium
+ usage: Final = _usage_from_spend_log(usage_object)
+ basis: Final = _pricing_basis(cost_breakdown)
+ billed_at_datetime: Final = _coerce_billed_at(billed_at)
+ prompt_caching: Final = (
+ calculate_prompt_caching_savings(
+ model_info=pricing,
+ usage=usage,
+ custom_llm_provider=identity.provider if identity else custom_llm_provider,
+ service_tier=basis.service_tier,
+ data_residency=basis.data_residency,
+ vertex_location=basis.vertex_location,
+ billed_at=billed_at_datetime,
+ )
+ if pricing is not None and usage is not None
+ else 0.0
+ )
gateway_injected_caching: Final = prompt_caching if gateway_injected_cache else 0.0
# The figure the logging path recorded wins, before the usage gate on purpose: a row
diff --git a/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py b/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py
index e1bd20ece6f..59f0938e338 100644
--- a/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py
+++ b/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py
@@ -49,6 +49,44 @@ def _local_model_cost_map(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(litellm, "model_cost", litellm.get_model_cost_map(url=""))
+@pytest.mark.parametrize("prompt_tokens", [100, 200000, 200001])
+@pytest.mark.parametrize("read_rate", [None, 0.0, 0.25e-6])
+@pytest.mark.parametrize("service_tier", [None, "priority"])
+def test_missing_cache_read_policy_preserves_billing(prompt_tokens, read_rate, service_tier):
+ info = {
+ "input_cost_per_token": 3e-6,
+ "input_cost_per_token_priority": 4e-6,
+ "input_cost_per_token_above_200k_tokens": 6e-6,
+ "input_cost_per_token_above_200k_tokens_priority": 8e-6,
+ "output_cost_per_token": 1e-6,
+ "cache_read_input_token_cost": read_rate,
+ }
+ usage = Usage(prompt_tokens=prompt_tokens, prompt_tokens_details={"cached_tokens": 100})
+ billed = _get_token_base_cost(info, usage, service_tier=service_tier)
+ savings = _get_token_base_cost(info, usage, service_tier=service_tier, missing_cache_read_uses_input=True)
+ prompt_cost, _ = generic_cost_per_token("policy-fixture", usage, "openai", service_tier=service_tier, model_info=info)
+ assert billed[4] == pytest.approx(read_rate or 0.0)
+ assert savings[:4] == billed[:4]
+ assert savings[4] == pytest.approx(billed[0] if read_rate is None else read_rate)
+ assert prompt_cost == pytest.approx((prompt_tokens - 100) * billed[0] + 100 * billed[4])
+
+
+def test_missing_cache_read_uses_off_peak_input_rate():
+ from datetime import datetime, timezone
+
+ info = {
+ "input_cost_per_token": 3e-6,
+ "off_peak_pricing": {"hours_utc": "00:00-23:59", "input_cost_per_token": 5e-6},
+ }
+ when = datetime(2026, 9, 7, 12, tzinfo=timezone.utc)
+ billed = _get_token_base_cost(info, Usage(prompt_tokens=100), current_time=when)
+ savings = _get_token_base_cost(
+ info, Usage(prompt_tokens=100), current_time=when, missing_cache_read_uses_input=True
+ )
+ assert billed[4] == 0.0
+ assert savings[0] == savings[4] == 5e-6
+
+
def test_reasoning_tokens_no_price_set(_local_model_cost_map):
# Use o1 - o1-mini was deprecated/renamed; o1 has same reasoning-token semantics
# (no separate output_cost_per_reasoning_token, so all completion tokens use output_cost_per_token)
diff --git a/tests/test_litellm/proxy/spend_tracking/test_savings.py b/tests/test_litellm/proxy/spend_tracking/test_savings.py
index 3f775d82b7f..cc8fdeb0160 100644
--- a/tests/test_litellm/proxy/spend_tracking/test_savings.py
+++ b/tests/test_litellm/proxy/spend_tracking/test_savings.py
@@ -1,4 +1,4 @@
-
+from typing import Final
import pytest
@@ -121,6 +121,147 @@ def _caching_usage(read: int, written: int, text: int = 10, out: int = 100) -> d
}
+@pytest.mark.parametrize(
+ "model,provider,prompt,reads,writes_5m,writes_1h,tier,region,location",
+ [
+ pytest.param("claude-sonnet-4-5", "anthropic", 50000, 20000, 20000, 0, None, None, None, id="5m"),
+ pytest.param("claude-sonnet-4-5", "anthropic", 50000, 20000, 0, 20000, None, None, None, id="1h"),
+ pytest.param("claude-sonnet-4-5", "anthropic", 50000, 20000, 12000, 8000, None, None, None, id="mixed-ttl"),
+ pytest.param("claude-sonnet-4-5", "anthropic", 199999, 80000, 20000, 0, None, None, None, id="below-200k"),
+ pytest.param("claude-sonnet-4-5", "anthropic", 200000, 80000, 20000, 0, None, None, None, id="exactly-200k"),
+ pytest.param("claude-sonnet-4-5", "anthropic", 200001, 80000, 20000, 0, None, None, None, id="above-200k"),
+ pytest.param("claude-sonnet-4-5", "anthropic", 250000, 80000, 12000, 8000, None, None, None, id="ttl-and-200k"),
+ pytest.param(
+ "claude-sonnet-4-5", "anthropic", 250000, 80000, 0, 20000, "priority", None, None, id="absent-tier"
+ ),
+ pytest.param("gpt-5.5", "openai", 100000, 80000, 0, 0, "priority", None, None, id="priority"),
+ pytest.param("gpt-5.5", "openai", 100000, 80000, 0, 0, "flex", None, None, id="flex"),
+ pytest.param("gpt-5.5", "openai", 100000, 80000, 0, 0, "batch", None, None, id="batch-resolver-fallback"),
+ pytest.param("gpt-5.5", "openai", 300000, 80000, 0, 0, "flex", None, None, id="flex-and-272k"),
+ pytest.param("gpt-5.5", "openai", 100000, 80000, 0, 0, "priority", "eu", None, id="priority-and-eu"),
+ pytest.param("gemini-2.5-pro", "vertex_ai", 250000, 80000, 20000, 0, None, None, None, id="variant-only-write"),
+ pytest.param("gemini-3.5-flash", "vertex_ai", 100000, 80000, 0, 0, None, None, "us-east5", id="vertex-region"),
+ pytest.param("gpt-5.5", "openai", 300000, 0, 0, 0, "priority", "eu", None, id="no-cache"),
+ ],
+)
+def test_caching_savings_agree_with_biller_on_the_request_pricing_basis(
+ model: str,
+ provider: str,
+ prompt: int,
+ reads: int,
+ writes_5m: int,
+ writes_1h: int,
+ tier: str | None,
+ region: str | None,
+ location: str | None,
+) -> None:
+ pricing: Final = litellm.get_model_info(model=model, custom_llm_provider=provider)
+ usage: Final = Usage(
+ prompt_tokens=prompt,
+ completion_tokens=100,
+ total_tokens=prompt + 100,
+ prompt_tokens_details={
+ "cached_tokens": reads,
+ "cache_creation_tokens": writes_5m + writes_1h,
+ "text_tokens": prompt - reads - writes_5m - writes_1h,
+ "cache_creation_token_details": {
+ "ephemeral_5m_input_tokens": writes_5m,
+ "ephemeral_1h_input_tokens": writes_1h,
+ },
+ },
+ )
+ uncached: Final = Usage(
+ prompt_tokens=prompt,
+ completion_tokens=100,
+ total_tokens=prompt + 100,
+ prompt_tokens_details={"text_tokens": prompt, "cached_tokens": 0, "cache_creation_tokens": 0},
+ )
+ costs: Final = tuple(
+ sum(
+ generic_cost_per_token(
+ model=model,
+ usage=arm,
+ custom_llm_provider=provider,
+ model_info=pricing,
+ service_tier=tier,
+ data_residency=region,
+ vertex_location=location,
+ )
+ )
+ for arm in (uncached, usage)
+ )
+ expected: Final = costs[0] - costs[1]
+ for attributed in (False, True):
+ result: Final = compute_savings_spend(
+ model=model,
+ custom_llm_provider=provider,
+ compression_saved_tokens=4389,
+ gateway_injected_cache=attributed,
+ usage_object=usage.model_dump(),
+ cost_breakdown={"service_tier": tier, "data_residency": region, "vertex_location": location},
+ billed_at="2026-09-07T12:00:00+00:00",
+ )
+ assert result.prompt_caching == pytest.approx(expected)
+ assert result.gateway_injected_caching == pytest.approx(expected if attributed else 0.0)
+ assert result.compression == pytest.approx(4389 * (pricing["input_cost_per_token"] or 0.0))
+ assert result.autorouter == 0.0
+ if reads + writes_5m + writes_1h == 0:
+ assert expected == 0.0
+
+
+def test_negative_ttl_counts_do_not_become_cache_write_credits() -> None:
+ results: Final = tuple(
+ compute_savings_spend(
+ model="claude-sonnet-4-5",
+ custom_llm_provider="anthropic",
+ compression_saved_tokens=0,
+ gateway_injected_cache=True,
+ usage_object={
+ "prompt_tokens": 6000,
+ "completion_tokens": 100,
+ "prompt_tokens_details": {
+ "text_tokens": 1000,
+ "cache_creation_tokens": 5000,
+ "cache_creation_token_details": {
+ "ephemeral_5m_input_tokens": short_count,
+ "ephemeral_1h_input_tokens": 5000,
+ },
+ },
+ },
+ )
+ for short_count in (-5000, 0)
+ )
+ assert results[0] == results[1]
+ assert results[0].prompt_caching < 0
+
+
+def test_unpublished_one_hour_price_uses_the_ordinary_write_price() -> None:
+ model: Final = "claude-4-opus-20250514"
+ pricing: Final = litellm.get_model_info(model=model, custom_llm_provider="anthropic")
+ assert pricing.get("cache_creation_input_token_cost_above_1hr") is None
+ assert pricing["cache_creation_input_token_cost"] > pricing["input_cost_per_token"]
+ results: Final = tuple(
+ compute_savings_spend(
+ model=model,
+ custom_llm_provider="anthropic",
+ compression_saved_tokens=0,
+ gateway_injected_cache=True,
+ usage_object={
+ "prompt_tokens": 6000,
+ "completion_tokens": 100,
+ "prompt_tokens_details": {
+ "text_tokens": 1000,
+ "cache_creation_tokens": 5000,
+ "cache_creation_token_details": ttl,
+ },
+ },
+ )
+ for ttl in (None, {"ephemeral_1h_input_tokens": 5000})
+ )
+ assert results[0] == results[1]
+ assert results[0].prompt_caching < 0
+
+
def test_prompt_caching_savings_nets_out_the_cache_write_premium():
"""A cache-writing request is only credited the read discount minus the write premium."""
input_cost, cache_read_cost = _anthropic_costs("claude-sonnet-5")
From 8e5a12057ab5733cb3c71e05aed8c29c5f295740 Mon Sep 17 00:00:00 2001
From: ryan-crabbe-berri
Date: Mon, 7 Sep 2026 14:55:43 -0700
Subject: [PATCH 122/123] feat(ui): list the ChatGPT subscription provider in
the Add Model form
The Add Model provider dropdown is driven entirely by provider_create_fields.json,
and chatgpt had no entry there, so the documented ChatGPT subscription setup was
unreachable from the Admin UI. Add the entry plus the dashboard enum, slug, logo and
placeholder mappings so the provider can be selected and its cost-map models listed.
The entry carries no credential fields on purpose: the chatgpt backend ignores
api_key and api_base and signs in through the device-code auth file on the proxy
host, so any field here would be inert.
Add a parity test that every LlmProviders value is either listed for Add Model or
frozen in an explicit unlisted set, so a new backend provider cannot silently miss
the dropdown again.
Claude-Session: https://claude.ai/code/session_011Tn3657NkV6ojLqewL64Kb
---
.../provider_create_fields.json | 7 ++
.../public_endpoints/test_public_endpoints.py | 86 +++++++++++++++++++
.../components/provider_info_helpers.test.tsx | 14 +++
.../src/components/provider_info_helpers.tsx | 4 +
4 files changed, 111 insertions(+)
diff --git a/litellm/proxy/public_endpoints/provider_create_fields.json b/litellm/proxy/public_endpoints/provider_create_fields.json
index 66f8c2ea36f..cd781abee26 100644
--- a/litellm/proxy/public_endpoints/provider_create_fields.json
+++ b/litellm/proxy/public_endpoints/provider_create_fields.json
@@ -688,6 +688,13 @@
],
"default_model_placeholder": "gpt-3.5-turbo"
},
+ {
+ "provider": "CHATGPT",
+ "provider_display_name": "ChatGPT Subscription",
+ "litellm_provider": "chatgpt",
+ "credential_fields": [],
+ "default_model_placeholder": "chatgpt/gpt-5.4"
+ },
{
"provider": "CLARIFAI",
"provider_display_name": "Clarifai",
diff --git a/tests/test_litellm/proxy/public_endpoints/test_public_endpoints.py b/tests/test_litellm/proxy/public_endpoints/test_public_endpoints.py
index 4a19ad3541c..fade7c9e7ee 100644
--- a/tests/test_litellm/proxy/public_endpoints/test_public_endpoints.py
+++ b/tests/test_litellm/proxy/public_endpoints/test_public_endpoints.py
@@ -1,5 +1,6 @@
import re
from datetime import datetime, timezone
+from typing import Final
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@@ -327,6 +328,91 @@ def test_cognition_provider_fields():
assert fields_by_key["api_base"]["required"] is False
+def test_chatgpt_provider_fields():
+ """The ChatGPT subscription provider must be selectable in the Add Model flow (LIT-7127).
+
+ Its backend signs in through the device-code auth file on the proxy host and ignores
+ api_key/api_base, so the entry carries no credential fields: any field here would be inert.
+ """
+ app_instance = FastAPI()
+ app_instance.include_router(router)
+ test_client = TestClient(app_instance)
+
+ response = test_client.get("/public/providers/fields")
+ assert response.status_code == 200
+ providers = response.json()
+
+ chatgpt = next((p for p in providers if p["provider"] == "CHATGPT"), None)
+ assert chatgpt is not None, "ChatGPT provider entry not found"
+
+ assert chatgpt["provider_display_name"] == "ChatGPT Subscription"
+ assert chatgpt["litellm_provider"] == LlmProviders.CHATGPT.value
+ assert chatgpt["default_model_placeholder"].startswith("chatgpt/")
+ assert chatgpt["credential_fields"] == []
+
+
+ADD_MODEL_UNLISTED_PROVIDERS: Final = frozenset(
+ {
+ "a2a",
+ "a2a_agent",
+ "amazon_nova",
+ "apertis",
+ "aws_polly",
+ "black_forest_labs",
+ "charity_engine",
+ "chutes",
+ "darkbloom",
+ "gdc",
+ "helicone",
+ "inception",
+ "langflow",
+ "langgraph",
+ "libertai",
+ "litellm_agent",
+ "manus",
+ "meta",
+ "modelscope",
+ "mongodb",
+ "nano-gpt",
+ "neosantara",
+ "parasail",
+ "pinstripes",
+ "poe",
+ "publicai",
+ "ragflow",
+ "reducto",
+ "s3_vectors",
+ "sagemaker_nova",
+ "scaleway",
+ "stability",
+ "synthetic",
+ "tencent",
+ "tensormesh",
+ "text-completion-inception",
+ "valkey",
+ "xiaomi_mimo",
+ "zai",
+ }
+)
+
+
+def test_every_backend_provider_is_listed_in_add_model_or_frozen_as_unlisted():
+ """A provider LiteLLM ships must be reachable from the Add Model dropdown, which is driven
+ entirely by /public/providers/fields (LIT-7127). Providers that predate this check are frozen
+ in ADD_MODEL_UNLISTED_PROVIDERS; a new provider gets a JSON entry rather than a line here.
+ """
+ app_instance = FastAPI()
+ app_instance.include_router(router)
+ test_client = TestClient(app_instance)
+
+ response = test_client.get("/public/providers/fields")
+ assert response.status_code == 200
+ listed = {p["litellm_provider"] for p in response.json()}
+
+ unlisted = {provider.value for provider in LlmProviders} - listed
+ assert unlisted == ADD_MODEL_UNLISTED_PROVIDERS
+
+
def test_google_ai_studio_provider_fields_expose_api_base():
"""The Google AI Studio (gemini) credential form must let admins set a custom
api_base so they can point at a Gemini-compatible gateway (e.g. a self-hosted
diff --git a/ui/litellm-dashboard/src/components/provider_info_helpers.test.tsx b/ui/litellm-dashboard/src/components/provider_info_helpers.test.tsx
index dfc737ddd45..4c68e302267 100644
--- a/ui/litellm-dashboard/src/components/provider_info_helpers.test.tsx
+++ b/ui/litellm-dashboard/src/components/provider_info_helpers.test.tsx
@@ -89,6 +89,16 @@ describe("provider_info_helpers", () => {
expect(result.logo).toBe(providerLogoMap[Providers.BedrockMantle]);
});
+ it("should map the chatgpt slug and CHATGPT enum key to the ChatGPT Subscription name and OpenAI logo", () => {
+ const fromSlug = getProviderLogoAndName("chatgpt");
+ expect(fromSlug.displayName).toBe("ChatGPT Subscription");
+ expect(fromSlug.logo).toContain("openai_small");
+
+ const fromEnumKey = getProviderLogoAndName("CHATGPT");
+ expect(fromEnumKey.displayName).toBe("ChatGPT Subscription");
+ expect(fromEnumKey.logo).toContain("openai_small");
+ });
+
it("should handle provider values case-insensitively", () => {
const result = getProviderLogoAndName("OPENAI");
expect(result.displayName).toBe(Providers.OpenAI);
@@ -272,6 +282,10 @@ describe("provider_info_helpers", () => {
expect(getPlaceholder(Providers.Cognition)).toBe("cognition/swe-1.7");
});
+ it("should return a chatgpt/ placeholder for the CHATGPT dropdown key", () => {
+ expect(getPlaceholder("CHATGPT")).toBe("chatgpt/gpt-5.4");
+ });
+
it("should return default gpt-3.5-turbo placeholder for unknown provider", () => {
expect(getPlaceholder("UnknownProvider" as any)).toBe("gpt-3.5-turbo");
});
diff --git a/ui/litellm-dashboard/src/components/provider_info_helpers.tsx b/ui/litellm-dashboard/src/components/provider_info_helpers.tsx
index d01a6a34cbe..72ec5990557 100644
--- a/ui/litellm-dashboard/src/components/provider_info_helpers.tsx
+++ b/ui/litellm-dashboard/src/components/provider_info_helpers.tsx
@@ -84,6 +84,7 @@ export enum Providers {
BASETEN = "Baseten",
BYTEZ = "Bytez",
Cerebras = "Cerebras",
+ CHATGPT = "ChatGPT Subscription",
CLARIFAI = "Clarifai",
CLOUDFLARE = "Cloudflare",
CODESTRAL = "Codestral",
@@ -198,6 +199,7 @@ export const provider_map: Record = {
BedrockMantle: "bedrock_mantle",
BYTEZ: "bytez",
Cerebras: "cerebras",
+ CHATGPT: "chatgpt",
CLARIFAI: "clarifai",
CLOUDFLARE: "cloudflare",
CODESTRAL: "codestral",
@@ -314,6 +316,7 @@ export const providerLogoMap: Partial> = {
[Providers.BedrockMantle]: bedrockLogo.src,
[Providers.SageMaker]: bedrockLogo.src,
[Providers.Cerebras]: cerebrasLogo.src,
+ [Providers.CHATGPT]: openaiSmallLogo.src,
[Providers.CLOUDFLARE]: cloudflareLogo.src,
[Providers.CODESTRAL]: mistralLogo.src,
[Providers.Cohere]: cohereLogo.src,
@@ -425,6 +428,7 @@ const providerPlaceholderMap: Partial> = {
[Providers.Azure]: "my-deployment",
[Providers.Azure_AI_Studio]: "azure_ai/command-r-plus",
[Providers.Bedrock]: "claude-3-opus",
+ [Providers.CHATGPT]: "chatgpt/gpt-5.4",
[Providers.Cognition]: "cognition/swe-1.7",
[Providers.Cursor]: "cursor/claude-4-sonnet",
[Providers.DeepInfra]: "deepinfra/",
From bb2db2d3f8e218c6a781e029223a8af903e9d6dc Mon Sep 17 00:00:00 2001
From: ryan-crabbe-berri
Date: Mon, 7 Sep 2026 15:12:41 -0700
Subject: [PATCH 123/123] test(proxy): drop docstrings from the Add Model
provider tests
Move the only guidance worth keeping into the parity assertion message so a
failing run tells the contributor to add a catalog entry instead of growing
the frozen unlisted set.
Claude-Session: https://claude.ai/code/session_011Tn3657NkV6ojLqewL64Kb
---
.../public_endpoints/test_public_endpoints.py | 14 ++++----------
1 file changed, 4 insertions(+), 10 deletions(-)
diff --git a/tests/test_litellm/proxy/public_endpoints/test_public_endpoints.py b/tests/test_litellm/proxy/public_endpoints/test_public_endpoints.py
index fade7c9e7ee..0d82ed778f5 100644
--- a/tests/test_litellm/proxy/public_endpoints/test_public_endpoints.py
+++ b/tests/test_litellm/proxy/public_endpoints/test_public_endpoints.py
@@ -329,11 +329,6 @@ def test_cognition_provider_fields():
def test_chatgpt_provider_fields():
- """The ChatGPT subscription provider must be selectable in the Add Model flow (LIT-7127).
-
- Its backend signs in through the device-code auth file on the proxy host and ignores
- api_key/api_base, so the entry carries no credential fields: any field here would be inert.
- """
app_instance = FastAPI()
app_instance.include_router(router)
test_client = TestClient(app_instance)
@@ -397,10 +392,6 @@ ADD_MODEL_UNLISTED_PROVIDERS: Final = frozenset(
def test_every_backend_provider_is_listed_in_add_model_or_frozen_as_unlisted():
- """A provider LiteLLM ships must be reachable from the Add Model dropdown, which is driven
- entirely by /public/providers/fields (LIT-7127). Providers that predate this check are frozen
- in ADD_MODEL_UNLISTED_PROVIDERS; a new provider gets a JSON entry rather than a line here.
- """
app_instance = FastAPI()
app_instance.include_router(router)
test_client = TestClient(app_instance)
@@ -410,7 +401,10 @@ def test_every_backend_provider_is_listed_in_add_model_or_frozen_as_unlisted():
listed = {p["litellm_provider"] for p in response.json()}
unlisted = {provider.value for provider in LlmProviders} - listed
- assert unlisted == ADD_MODEL_UNLISTED_PROVIDERS
+ assert unlisted == ADD_MODEL_UNLISTED_PROVIDERS, (
+ "Add Model dropdown drift: give the new provider an entry in provider_create_fields.json "
+ "rather than adding it to ADD_MODEL_UNLISTED_PROVIDERS"
+ )
def test_google_ai_studio_provider_fields_expose_api_base():