Merge remote-tracking branch 'origin/litellm_internal_staging' into litellm_/key-info-badge-styling-b293cc

This commit is contained in:
Yuneng Jiang 2026-08-14 16:36:32 -07:00
commit f9f016dc1d
No known key found for this signature in database
9 changed files with 182 additions and 17 deletions

View file

@ -1599,6 +1599,9 @@ class MCPServerManager:
manual_token_url,
)
use_issuer_anchor = _uses_issuer_anchor(manual_issuer, is_discovery_auth_type or obo_needs_discovery)
configured_authorization_url = manual_authorization_url
configured_token_url = manual_token_url
configured_registration_url = manual_registration_url
manual_authorization_url, manual_token_url, manual_registration_url = _endpoints_yield_to_issuer(
manual_issuer,
is_discovery_auth_type,
@ -1725,6 +1728,9 @@ class MCPServerManager:
authorization_url=resolved_authorization_url,
token_url=resolved_token_url,
registration_url=resolved_registration_url,
configured_authorization_url=configured_authorization_url,
configured_token_url=configured_token_url,
configured_registration_url=configured_registration_url,
token_endpoint_auth_method=server_config.get("token_endpoint_auth_method", None),
# TODO: utility fn the default values
transport=server_config.get("transport", MCPTransport.http),
@ -2170,6 +2176,9 @@ class MCPServerManager:
is_discovery_auth_type
or self._obo_needs_endpoint_discovery(auth_type, token_exchange_endpoint, manual_token_url),
)
configured_authorization_url: Final = manual_authorization_url
configured_token_url: Final = manual_token_url
configured_registration_url: Final = manual_registration_url
manual_authorization_url, manual_token_url, manual_registration_url = _endpoints_yield_to_issuer(
manual_issuer,
is_discovery_auth_type,
@ -2222,6 +2231,9 @@ class MCPServerManager:
authorization_url=manual_authorization_url or getattr(gated_oauth_metadata, "authorization_url", None),
token_url=manual_token_url or getattr(gated_oauth_metadata, "token_url", None),
registration_url=manual_registration_url or getattr(gated_oauth_metadata, "registration_url", None),
configured_authorization_url=configured_authorization_url,
configured_token_url=configured_token_url,
configured_registration_url=configured_registration_url,
token_endpoint_auth_method=(
credentials_dict.get("token_endpoint_auth_method") if credentials_dict else None
),
@ -5858,9 +5870,9 @@ class MCPServerManager:
args=getattr(server, "args", None) or [],
env=getattr(server, "env", None) or {},
issuer=server.issuer,
authorization_url=server.authorization_url,
token_url=server.token_url,
registration_url=server.registration_url,
authorization_url=server.configured_authorization_url or server.authorization_url,
token_url=server.configured_token_url or server.token_url,
registration_url=server.configured_registration_url or server.registration_url,
oauth2_flow=server.oauth2_flow,
dcr_bridge=server.dcr_bridge,
token_exchange_endpoint=server.token_exchange_endpoint,
@ -5968,9 +5980,9 @@ class MCPServerManager:
args=getattr(server, "args", None) or [],
env=getattr(server, "env", None) or {},
issuer=server.issuer,
authorization_url=server.authorization_url,
token_url=server.token_url,
registration_url=server.registration_url,
authorization_url=server.configured_authorization_url or server.authorization_url,
token_url=server.configured_token_url or server.token_url,
registration_url=server.configured_registration_url or server.registration_url,
oauth2_flow=server.oauth2_flow,
token_exchange_endpoint=server.token_exchange_endpoint,
audience=server.audience,

View file

@ -2980,7 +2980,7 @@
},
{
"provider": "Hosted_Vllm",
"provider_display_name": "vllm",
"provider_display_name": "Hosted vLLM",
"litellm_provider": "hosted_vllm",
"credential_fields": [
{
@ -3008,7 +3008,7 @@
},
{
"provider": "VLLM",
"provider_display_name": "Vllm",
"provider_display_name": "Local vLLM",
"litellm_provider": "vllm",
"credential_fields": [
{

View file

@ -71,6 +71,12 @@ class MCPServer(BaseModel):
authorization_url: str | None = None
token_url: str | None = None
registration_url: str | None = None
# Endpoints exactly as an admin stored them, unlike the resolved fields above which an anchored
# issuer empties (RFC 8414 section 3.3). Management reads serve these so the edit form does not
# load blanks and then save those blanks over the stored config.
configured_authorization_url: str | None = None
configured_token_url: str | None = None
configured_registration_url: str | None = None
# How the gateway authenticates to the upstream token endpoint. When
# "client_secret_basic" the credentials go in an HTTP Basic Authorization
# header (omitted from the body); None defaults to "client_secret_post".

View file

@ -479,6 +479,35 @@ class TestMCPServerManager:
assert server.oauth2_flow == "authorization_code"
assert server.needs_user_oauth_token is True
@pytest.mark.asyncio
async def test_load_servers_from_config_keeps_configured_endpoints_for_management_view(self):
"""A yaml server with a pinned issuer still reports its configured endpoints to the management
view, even though the runtime fields are empty because the anchored issuer is the sole endpoint
source. The dashboard edits that view, so emptied values there load as blank fields and the next
save writes the blanks over the config."""
manager = MCPServerManager()
config = self._oauth2_config(
oauth2_flow="authorization_code",
issuer="https://idp.example.com",
authorization_url="https://example.com/oauth/authorize",
token_url="https://example.com/oauth/token",
registration_url="https://example.com/oauth/register",
)
with patch.object(manager, "_fetch_issuer_anchored_oauth_metadata", new=AsyncMock(return_value=None)):
await manager.load_servers_from_config(config)
server = next(iter(manager.config_mcp_servers.values()))
assert server.authorization_url is None
assert server.token_url is None
assert server.registration_url is None
view = manager._build_mcp_server_table(server)
assert view.authorization_url == "https://example.com/oauth/authorize"
assert view.token_url == "https://example.com/oauth/token"
assert view.registration_url == "https://example.com/oauth/register"
@pytest.mark.asyncio
async def test_load_servers_from_config_rejects_uncorroborated_endpoints_but_keeps_resource_scopes(self):
"""A yaml server with a manual authorization_url has the same config-time mix-up exposure as a
@ -1611,6 +1640,43 @@ class TestMCPServerManager:
assert built.token_url == "https://idp.example.com/token"
assert built.token_url != "https://attacker.example.com/steal"
@pytest.mark.asyncio
async def test_management_view_keeps_stored_endpoints_when_issuer_is_pinned(self):
"""A pinned issuer empties the endpoints the runtime uses, but the management view must still
report what the admin stored. Serving the emptied values made the dashboard edit form load the
three endpoint fields blank, so saving with no edits sent them back as explicit nulls and wiped
the row, and re-entering them looked like it never saved."""
manager = MCPServerManager()
row = LiteLLM_MCPServerTable(
server_id="issuer-anchored-management-view",
alias="issuer_anchored_management_view",
description="issuer pinned with admin-entered endpoints",
url="https://up.example.com/mcp",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
oauth2_flow="authorization_code",
issuer="https://idp.example.com",
authorization_url="https://up.example.com/oauth/authorize",
token_url="https://up.example.com/oauth/token",
registration_url="https://up.example.com/oauth/register",
created_at=datetime.now(),
updated_at=datetime.now(),
)
with patch.object(manager, "_fetch_issuer_anchored_oauth_metadata", new=AsyncMock(return_value=None)):
built = await manager.build_mcp_server_from_table(row, credentials_are_encrypted=False)
assert built.authorization_url is None
assert built.token_url is None
assert built.registration_url is None
view = manager._build_mcp_server_table(built)
assert view.issuer == "https://idp.example.com"
assert view.authorization_url == "https://up.example.com/oauth/authorize"
assert view.token_url == "https://up.example.com/oauth/token"
assert view.registration_url == "https://up.example.com/oauth/register"
@pytest.mark.asyncio
@pytest.mark.parametrize(
"advertised_authorization_url",

View file

@ -243,6 +243,33 @@ def test_bedrock_mantle_provider_fields():
assert fields_by_key["api_base"]["field_type"] == "text"
def test_vllm_provider_display_names_are_distinct():
"""Hosted and local vLLM must not share a dropdown label.
The Add Model provider dropdown is driven by /public/providers/fields.
Both entries previously rendered as near-identical "vllm"/"Vllm" rows
with the same logo, so admins could not tell them apart.
"""
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()
hosted = next((p for p in providers if p["provider"] == "Hosted_Vllm"), None)
local = next((p for p in providers if p["provider"] == "VLLM"), None)
assert hosted is not None, "Hosted vLLM provider entry not found"
assert local is not None, "Local vLLM provider entry not found"
assert hosted["provider_display_name"] == "Hosted vLLM"
assert local["provider_display_name"] == "Local vLLM"
assert hosted["provider_display_name"].casefold() != local["provider_display_name"].casefold()
assert hosted["litellm_provider"] == "hosted_vllm"
assert local["litellm_provider"] == "vllm"
def test_nvidia_riva_provider_fields():
app_instance = FastAPI()
app_instance.include_router(router)

View file

@ -66,6 +66,36 @@ const totals = (overrides: Partial<Totals> = {}): Totals => ({
...overrides,
});
const zeroBucket = { turns: 0, hits: 0, hit_rate_pct: 0 };
const zeroCache: AutoRouterCacheStats = {
coverage_pct: 0,
hit_rate_pct: 0,
same_model: zeroBucket,
first_visit: zeroBucket,
return_to_tier: zeroBucket,
unordered_turns: 0,
return_misses_expired: 0,
return_misses_within_ttl: 0,
return_misses_unknown: 0,
ttl_5m_turns: 0,
ttl_1h_turns: 0,
};
const zeroTotals: Totals = {
sessions: 0,
turns: 0,
avg_turns_per_session: 0,
avg_session_seconds: 0,
avg_tokens_per_session: 0,
spend: 0,
saved_spend: 0,
baseline_spend: 0,
saved_pct: 0,
saved_per_session: 0,
cache: zeroCache,
};
const group = (overrides: Partial<AutoRouterBenchmarkGroup> = {}): AutoRouterBenchmarkGroup => ({
router_name: "claude-auto",
router_type: "complexity",
@ -162,6 +192,7 @@ describe("AutoRouterBenchmarksTab", () => {
expect(screen.getByText("97.7%")).toBeInTheDocument();
expect(screen.getByText("24.3%")).toBeInTheDocument();
expect(screen.getByText("81.6%")).toBeInTheDocument();
expect(screen.getByRole("img", { name: "Share of turns by bucket" })).not.toHaveClass("bg-muted");
});
it("summarizes the cache column from the bucketed turns, not the session turns", () => {
@ -252,11 +283,25 @@ describe("AutoRouterBenchmarksTab", () => {
expect(screen.getByText("Auto-router usage is unavailable right now")).toBeInTheDocument();
});
it("says so when there are no auto-router sessions at all", () => {
mockHook({ data: response([]) });
it("renders the full dashboard with zeroed stats when the window has no sessions", () => {
mockHook({ data: response([], zeroTotals) });
renderTab();
expect(screen.getByText("No auto-router sessions in this window yet")).toBeInTheDocument();
expect(screen.getByText("Total estimated savings")).toBeInTheDocument();
expect(screen.getAllByText("$0.00")).toHaveLength(4);
expect(screen.getByText("across 0 sessions")).toBeInTheDocument();
expect(screen.getByText("0s")).toBeInTheDocument();
expect(screen.getByText(/turns measured/)).toBeInTheDocument();
expect(screen.getAllByText("0.0%").length).toBeGreaterThan(0);
expect(screen.getByRole("img", { name: "Share of turns by bucket" })).toHaveClass("bg-muted");
});
it("shows the savings delta as an unsigned zero when nothing was saved", () => {
mockHook({ data: response([], zeroTotals) });
renderTab();
expect(screen.getByText("0%")).toBeInTheDocument();
expect(screen.queryByText("-0%")).not.toBeInTheDocument();
});
it("requests the default thirty day window and widens or narrows it from the picker", () => {
@ -303,7 +348,7 @@ describe("AutoRouterBenchmarksTab", () => {
});
it("keeps the window picker reachable while a window has no sessions", () => {
mockHook({ data: response([]) });
mockHook({ data: response([], zeroTotals) });
renderTab();
expect(screen.getByRole("tab", { name: "30d" })).toBeInTheDocument();

View file

@ -64,7 +64,7 @@ const HeroCard: React.FC<{ view: BenchmarkView }> = ({ view }) => {
variant="secondary"
className={cheaper ? "bg-emerald-50 text-emerald-700" : "bg-red-50 text-destructive"}
>
{cheaper ? "-" : "+"}
{stats.saved_spend !== 0 && (cheaper ? "-" : "+")}
{Math.abs(stats.saved_pct).toFixed(0)}%
</Badge>
</div>
@ -95,7 +95,7 @@ const StackedTurnBar: React.FC<{ buckets: BucketRow[] }> = ({ buckets }) => {
return (
<div className="flex flex-col gap-1">
<div
className="flex h-2.5 w-full gap-0.5 overflow-hidden rounded-sm"
className={`flex h-2.5 w-full gap-0.5 overflow-hidden rounded-sm ${segments.length === 0 ? "bg-muted" : ""}`}
role="img"
aria-label="Share of turns by bucket"
>
@ -230,7 +230,6 @@ const BenchmarksBody: React.FC<BenchmarksBodyProps> = ({ isPending, error, data,
return <Message>Auto-router usage is visible to proxy admin roles only</Message>;
}
if (error || !data) return <Message>Auto-router usage is unavailable right now</Message>;
if (data.groups.length === 0) return <Message>No auto-router sessions in this window yet</Message>;
const view = viewFor(data, selectedKey);
const stats = view.stats;

View file

@ -94,6 +94,16 @@ describe("provider_info_helpers", () => {
expect(result.displayName).toBe(Providers.ZAI);
});
it("should give hosted_vllm and vllm distinct display names", () => {
const hosted = getProviderLogoAndName("hosted_vllm");
const local = getProviderLogoAndName("vllm");
expect(hosted.displayName).toBe("Hosted vLLM");
expect(local.displayName).toBe("Local vLLM");
expect(hosted.displayName.toLowerCase()).not.toBe(local.displayName.toLowerCase());
expect(hosted.logo).toBe(providerLogoMap[Providers.Hosted_Vllm]);
expect(local.logo).toBe(providerLogoMap[Providers.VLLM]);
});
it("should resolve the nvidia_riva provider value to the Nvidia Riva display name and logo", () => {
const result = getProviderLogoAndName("nvidia_riva");
expect(result.displayName).toBe(Providers.NVIDIA_RIVA);

View file

@ -110,7 +110,7 @@ export enum Providers {
GradientAI = "GradientAI",
Groq = "Groq",
HEROKU = "Heroku",
Hosted_Vllm = "vllm",
Hosted_Vllm = "Hosted vLLM",
HUGGINGFACE = "Huggingface",
HYPERBOLIC = "Hyperbolic",
Infinity = "Infinity",
@ -162,7 +162,7 @@ export enum Providers {
VERCEL_AI_GATEWAY = "Vercel Ai Gateway",
Vertex_AI = "Vertex AI (Anthropic, Gemini, etc.)",
VERTEX_AI_BETA = "Vertex Ai Beta",
VLLM = "Vllm",
VLLM = "Local vLLM",
VolcEngine = "VolcEngine",
Voyage = "Voyage AI",
WANDB = "Wandb",