diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20260713230852_add_key_type_to_litellm_verification_token/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260713230852_add_key_type_to_litellm_verification_token/migration.sql new file mode 100644 index 00000000000..708b7601346 --- /dev/null +++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260713230852_add_key_type_to_litellm_verification_token/migration.sql @@ -0,0 +1,6 @@ +-- AlterTable +ALTER TABLE "LiteLLM_DeletedVerificationToken" ADD COLUMN "key_type" TEXT; + +-- AlterTable +ALTER TABLE "LiteLLM_VerificationToken" ADD COLUMN "key_type" TEXT; + diff --git a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma index fb4d8d0b5a3..a23cecc3911 100644 --- a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma +++ b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma @@ -422,6 +422,7 @@ model LiteLLM_VerificationToken { budget_reset_at DateTime? allowed_cache_controls String[] @default([]) allowed_routes String[] @default([]) + key_type String? policies String[] @default([]) access_group_ids String[] @default([]) model_spend Json @default("{}") @@ -516,6 +517,7 @@ model LiteLLM_DeletedVerificationToken { budget_reset_at DateTime? allowed_cache_controls String[] @default([]) allowed_routes String[] @default([]) + key_type String? policies String[] @default([]) access_group_ids String[] @default([]) model_spend Json @default("{}") diff --git a/litellm/models/verification_token.py b/litellm/models/verification_token.py index d67726be584..519066b8266 100644 --- a/litellm/models/verification_token.py +++ b/litellm/models/verification_token.py @@ -36,6 +36,7 @@ class LiteLLM_VerificationToken(LiteLLMPydanticObjectBase): budget_reset_at: Optional[datetime] = None allowed_cache_controls: Optional[list] = [] allowed_routes: Optional[list] = [] + key_type: str | None = None permissions: Dict = {} model_spend: Dict = {} model_max_budget: Dict = {} diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 23fe7730c17..008fdd0d50b 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -1118,6 +1118,7 @@ class GenerateKeyRequest(KeyRequestBase): class GenerateKeyResponse(KeyRequestBase): key: str # type: ignore key_name: Optional[str] = None + key_type: str | None = None expires: Optional[datetime] = None user_id: Optional[str] = None token_id: Optional[str] = None diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index b128b0ea57e..a3679b84bd6 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -468,7 +468,10 @@ def handle_key_type(data: GenerateKeyRequest, data_json: dict) -> dict: Handle the key type. """ key_type = data.key_type - data_json.pop("key_type", None) + if key_type is None: + data_json.pop("key_type", None) + return data_json + data_json["key_type"] = key_type.value if key_type == LiteLLMKeyType.LLM_API: data_json["allowed_routes"] = ["llm_api_routes"] elif key_type == LiteLLMKeyType.MANAGEMENT: @@ -3566,6 +3569,7 @@ async def generate_key_helper_fn( created_by: Optional[str] = None, updated_by: Optional[str] = None, allowed_routes: Optional[list] = None, + key_type: str | None = None, sso_user_id: Optional[str] = None, object_permission_id: Optional[str] = None, # object_permission_id <-> LiteLLM_ObjectPermissionTable object_permission: Optional[LiteLLM_ObjectPermissionBase] = None, @@ -3706,6 +3710,7 @@ async def generate_key_helper_fn( "created_by": created_by, "updated_by": updated_by, "allowed_routes": allowed_routes or [], + "key_type": key_type, "object_permission_id": object_permission_id, "router_settings": router_settings_json, "access_group_ids": access_group_ids or [], diff --git a/litellm/proxy/schema.prisma b/litellm/proxy/schema.prisma index fb4d8d0b5a3..a23cecc3911 100644 --- a/litellm/proxy/schema.prisma +++ b/litellm/proxy/schema.prisma @@ -422,6 +422,7 @@ model LiteLLM_VerificationToken { budget_reset_at DateTime? allowed_cache_controls String[] @default([]) allowed_routes String[] @default([]) + key_type String? policies String[] @default([]) access_group_ids String[] @default([]) model_spend Json @default("{}") @@ -516,6 +517,7 @@ model LiteLLM_DeletedVerificationToken { budget_reset_at DateTime? allowed_cache_controls String[] @default([]) allowed_routes String[] @default([]) + key_type String? policies String[] @default([]) access_group_ids String[] @default([]) model_spend Json @default("{}") diff --git a/schema.prisma b/schema.prisma index fb4d8d0b5a3..a23cecc3911 100644 --- a/schema.prisma +++ b/schema.prisma @@ -422,6 +422,7 @@ model LiteLLM_VerificationToken { budget_reset_at DateTime? allowed_cache_controls String[] @default([]) allowed_routes String[] @default([]) + key_type String? policies String[] @default([]) access_group_ids String[] @default([]) model_spend Json @default("{}") @@ -516,6 +517,7 @@ model LiteLLM_DeletedVerificationToken { budget_reset_at DateTime? allowed_cache_controls String[] @default([]) allowed_routes String[] @default([]) + key_type String? policies String[] @default([]) access_group_ids String[] @default([]) model_spend Json @default("{}") 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 db6d3489830..c9abdf09a5d 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 @@ -12272,6 +12272,55 @@ async def test_bulk_update_team_keys_team_member_no_permission(monkeypatch): mock.update_data.assert_not_called() +def test_handle_key_type_persists_key_type_and_derives_routes(): + """`handle_key_type` keeps `key_type` in the payload (so it is persisted on + the token) while still deriving the `allowed_routes` preset. Regression for + the UI showing scoped keys as "All Proxy Models": the frontend now reads the + persisted `key_type` instead of reverse-mapping the preset string.""" + from litellm.proxy._types import GenerateKeyRequest, LiteLLMKeyType + from litellm.proxy.management_endpoints.key_management_endpoints import ( + handle_key_type, + ) + + cases = { + LiteLLMKeyType.MANAGEMENT: ("management", ["management_routes"]), + LiteLLMKeyType.READ_ONLY: ("read_only", ["info_routes"]), + LiteLLMKeyType.LLM_API: ("llm_api", ["llm_api_routes"]), + } + for key_type, (expected_type, expected_routes) in cases.items(): + data = GenerateKeyRequest(key_type=key_type) + out = handle_key_type(data, {"key_type": key_type}) + assert out["key_type"] == expected_type + assert out["allowed_routes"] == expected_routes + + +def test_handle_key_type_default_persists_type_without_forcing_routes(): + """`default` is persisted but must not overwrite an explicit `allowed_routes` + (e.g. a SCIM key created with `["/scim/*"]` and no explicit key_type).""" + from litellm.proxy._types import GenerateKeyRequest, LiteLLMKeyType + from litellm.proxy.management_endpoints.key_management_endpoints import ( + handle_key_type, + ) + + data = GenerateKeyRequest(key_type=LiteLLMKeyType.DEFAULT) + out = handle_key_type(data, {"allowed_routes": ["/scim/*"], "key_type": LiteLLMKeyType.DEFAULT}) + assert out["key_type"] == "default" + assert out["allowed_routes"] == ["/scim/*"] + + +def test_handle_key_type_none_drops_key_type(): + """When no `key_type` is supplied the payload must not carry a `key_type` + entry, so old keys stay `null` and the frontend keeps its route fallback.""" + from litellm.proxy._types import GenerateKeyRequest + from litellm.proxy.management_endpoints.key_management_endpoints import ( + handle_key_type, + ) + + data = GenerateKeyRequest(key_type=None) + out = handle_key_type(data, {"key_type": None}) + assert "key_type" not in out + + # ---- pydantic-layer validation ------------------------------------------- diff --git a/ui/litellm-dashboard/src/components/VirtualKeysPage/keyTableColumns.tsx b/ui/litellm-dashboard/src/components/VirtualKeysPage/keyTableColumns.tsx index 96e57b1fb87..133ff89a898 100644 --- a/ui/litellm-dashboard/src/components/VirtualKeysPage/keyTableColumns.tsx +++ b/ui/litellm-dashboard/src/components/VirtualKeysPage/keyTableColumns.tsx @@ -327,7 +327,13 @@ export const getKeyTableColumns = ({ header: "Models", size: 220, enableSorting: false, - cell: (info) => , + cell: (info) => ( + + ), }, { id: "rate_limits", diff --git a/ui/litellm-dashboard/src/components/key_scope.test.ts b/ui/litellm-dashboard/src/components/key_scope.test.ts new file mode 100644 index 00000000000..7c20a1baece --- /dev/null +++ b/ui/litellm-dashboard/src/components/key_scope.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; + +import { deriveKeyModelScope } from "./key_scope"; + +describe("deriveKeyModelScope", () => { + it("treats unrestricted keys (null/empty allowed_routes) as full model access", () => { + expect(deriveKeyModelScope(null)).toEqual({ hasModelAccess: true, label: null }); + expect(deriveKeyModelScope(undefined)).toEqual({ hasModelAccess: true, label: null }); + expect(deriveKeyModelScope([])).toEqual({ hasModelAccess: true, label: null }); + }); + + it("classifies SCIM keys as no model access", () => { + expect(deriveKeyModelScope(["/scim/*"])).toEqual({ hasModelAccess: false, label: "SCIM" }); + expect(deriveKeyModelScope(["/scim/v2/Users", "/scim/v2/Groups"])).toEqual({ + hasModelAccess: false, + label: "SCIM", + }); + }); + + it("classifies management-only keys as no model access", () => { + expect(deriveKeyModelScope(["management_routes"])).toEqual({ hasModelAccess: false, label: "Management" }); + }); + + it("classifies read-only keys as no model access", () => { + expect(deriveKeyModelScope(["info_routes"])).toEqual({ hasModelAccess: false, label: "Read-only" }); + }); + + it("leaves LLM-API and custom scopes with model access (default rendering)", () => { + expect(deriveKeyModelScope(["llm_api_routes"])).toEqual({ hasModelAccess: true, label: null }); + expect(deriveKeyModelScope(["/chat/completions"])).toEqual({ hasModelAccess: true, label: null }); + expect(deriveKeyModelScope(["management_routes", "llm_api_routes"])).toEqual({ + hasModelAccess: true, + label: null, + }); + }); + + it("prefers a persisted key_type over allowed_routes for the no-inference buckets", () => { + expect(deriveKeyModelScope([], "management")).toEqual({ hasModelAccess: false, label: "Management" }); + expect(deriveKeyModelScope([], "read_only")).toEqual({ hasModelAccess: false, label: "Read-only" }); + expect(deriveKeyModelScope(["some_future_mgmt_preset"], "management")).toEqual({ + hasModelAccess: false, + label: "Management", + }); + }); + + it("falls back to allowed_routes for null/default/llm_api key_type", () => { + expect(deriveKeyModelScope(["/scim/*"], null)).toEqual({ hasModelAccess: false, label: "SCIM" }); + expect(deriveKeyModelScope(["/scim/*"], "default")).toEqual({ hasModelAccess: false, label: "SCIM" }); + expect(deriveKeyModelScope([], "default")).toEqual({ hasModelAccess: true, label: null }); + expect(deriveKeyModelScope([], "llm_api")).toEqual({ hasModelAccess: true, label: null }); + }); +}); diff --git a/ui/litellm-dashboard/src/components/key_scope.ts b/ui/litellm-dashboard/src/components/key_scope.ts new file mode 100644 index 00000000000..01dc595b4f1 --- /dev/null +++ b/ui/litellm-dashboard/src/components/key_scope.ts @@ -0,0 +1,49 @@ +export interface KeyModelScope { + hasModelAccess: boolean; + label: string | null; +} + +const MANAGEMENT_ROUTES_PRESET = "management_routes"; +const INFO_ROUTES_PRESET = "info_routes"; +const SCIM_ROUTE_PREFIX = "/scim"; + +const MANAGEMENT_SCOPE: KeyModelScope = { hasModelAccess: false, label: "Management" }; +const READ_ONLY_SCOPE: KeyModelScope = { hasModelAccess: false, label: "Read-only" }; +const SCIM_SCOPE: KeyModelScope = { hasModelAccess: false, label: "SCIM" }; +const FULL_MODEL_ACCESS: KeyModelScope = { hasModelAccess: true, label: null }; + +const isScimRoute = (route: string): boolean => route.startsWith(SCIM_ROUTE_PREFIX); + +const isOnlyPreset = (allowedRoutes: string[], preset: string): boolean => + allowedRoutes.length === 1 && allowedRoutes[0] === preset; + +export const deriveKeyModelScope = ( + allowedRoutes: string[] | null | undefined, + keyType?: string | null, +): KeyModelScope => { + if (keyType === "management") { + return MANAGEMENT_SCOPE; + } + + if (keyType === "read_only") { + return READ_ONLY_SCOPE; + } + + if (!Array.isArray(allowedRoutes) || allowedRoutes.length === 0) { + return FULL_MODEL_ACCESS; + } + + if (allowedRoutes.every(isScimRoute)) { + return SCIM_SCOPE; + } + + if (isOnlyPreset(allowedRoutes, MANAGEMENT_ROUTES_PRESET)) { + return MANAGEMENT_SCOPE; + } + + if (isOnlyPreset(allowedRoutes, INFO_ROUTES_PRESET)) { + return READ_ONLY_SCOPE; + } + + return FULL_MODEL_ACCESS; +}; diff --git a/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx b/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx index a3d1ad2c4db..e1c1fcb232c 100644 --- a/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx +++ b/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx @@ -47,6 +47,7 @@ export interface KeyResponse { budget_reset_at: string; allowed_cache_controls: string[]; allowed_routes: string[]; + key_type: string | null; permissions: Record; model_spend: Record; model_max_budget: Record; diff --git a/ui/litellm-dashboard/src/components/shared/table_cells/models_cell.test.tsx b/ui/litellm-dashboard/src/components/shared/table_cells/models_cell.test.tsx index d3fad1d3244..3a618114613 100644 --- a/ui/litellm-dashboard/src/components/shared/table_cells/models_cell.test.tsx +++ b/ui/litellm-dashboard/src/components/shared/table_cells/models_cell.test.tsx @@ -14,6 +14,30 @@ describe("ModelsCell", () => { expect(screen.getByText("All Proxy Models")).toBeInTheDocument(); }); + it("shows 'No model access' for scope-restricted keys with an empty model list", () => { + const { rerender } = render(); + expect(screen.getByText("No model access")).toBeInTheDocument(); + expect(screen.queryByText("All Proxy Models")).not.toBeInTheDocument(); + rerender(); + expect(screen.getByText("No model access")).toBeInTheDocument(); + rerender(); + expect(screen.getByText("No model access")).toBeInTheDocument(); + }); + + it("still shows 'All Proxy Models' for empty models when the key is not scope-restricted", () => { + render(); + expect(screen.getByText("All Proxy Models")).toBeInTheDocument(); + expect(screen.queryByText("No model access")).not.toBeInTheDocument(); + }); + + it("uses a persisted key_type to render 'No model access' regardless of allowed_routes", () => { + const { rerender } = render(); + expect(screen.getByText("No model access")).toBeInTheDocument(); + expect(screen.queryByText("All Proxy Models")).not.toBeInTheDocument(); + rerender(); + expect(screen.getByText("No model access")).toBeInTheDocument(); + }); + it("renders every model with no overflow badge when at or below the limit", () => { render(); expect(screen.getByText("gpt-4o")).toBeInTheDocument(); diff --git a/ui/litellm-dashboard/src/components/shared/table_cells/models_cell.tsx b/ui/litellm-dashboard/src/components/shared/table_cells/models_cell.tsx index 712d8511c78..81928d6fe87 100644 --- a/ui/litellm-dashboard/src/components/shared/table_cells/models_cell.tsx +++ b/ui/litellm-dashboard/src/components/shared/table_cells/models_cell.tsx @@ -1,5 +1,6 @@ "use client"; +import { deriveKeyModelScope } from "@/components/key_scope"; import { getModelDisplayName } from "@/components/key_team_helpers/fetch_available_models_team_key"; import { Badge } from "@/components/ui/badge"; @@ -8,6 +9,8 @@ import { CellTooltip } from "./cell_tooltip"; interface ModelsCellProps { models: string[] | null | undefined; maxVisible?: number; + allowedRoutes?: string[] | null; + keyType?: string | null; } const WILDCARD_MODEL = "all-proxy-models"; @@ -20,8 +23,21 @@ const formatModel = (model: string): string => { return name.length > 30 ? `${name.slice(0, 30)}...` : name; }; -export function ModelsCell({ models, maxVisible = 3 }: ModelsCellProps) { +export function ModelsCell({ models, maxVisible = 3, allowedRoutes, keyType }: ModelsCellProps) { if (!Array.isArray(models) || models.length === 0) { + const scope = deriveKeyModelScope(allowedRoutes, keyType); + if (!scope.hasModelAccess) { + return ( + + No model access + + } + /> + ); + } return All Proxy Models; } diff --git a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx index eeccc7482e3..66690e5478f 100644 --- a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx @@ -18,6 +18,7 @@ import { Popover, Tooltip, Typography } from "antd"; import DefaultProxyAdminTag from "../common_components/DefaultProxyAdminTag"; import React, { useCallback, useEffect, useMemo, useState } from "react"; import { getModelDisplayName } from "../key_team_helpers/fetch_available_models_team_key"; +import { deriveKeyModelScope } from "../key_scope"; import { KeyResponse, Team } from "../key_team_helpers/key_list"; import { Organization } from "../networking"; import KeyInfoView from "../templates/key_info_view"; @@ -339,14 +340,24 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi enableSorting: false, cell: (info) => { const models = info.getValue() as string[]; + const scope = deriveKeyModelScope(info.row.original.allowed_routes, info.row.original.key_type); + const emptyModelsBadge = !scope.hasModelAccess ? ( + + + No model access + + + ) : ( + + All Proxy Models + + ); return (
{Array.isArray(models) ? (
{models.length === 0 ? ( - - All Proxy Models - + emptyModelsBadge ) : ( <>
diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 43bb465ec58..1d620e9d153 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -24026,6 +24026,8 @@ export interface components { key_alias?: string | null; /** Key Name */ key_name?: string | null; + /** Key Type */ + key_type?: string | null; /** Litellm Budget Table */ litellm_budget_table?: unknown | null; /** Max Budget */ @@ -25026,6 +25028,8 @@ export interface components { key_name?: string | null; /** Key Rotation At */ key_rotation_at?: string | null; + /** Key Type */ + key_type?: string | null; /** Last Active */ last_active?: string | null; /** Last Rotation At */ @@ -26414,6 +26418,8 @@ export interface components { key_name?: string | null; /** Key Rotation At */ key_rotation_at?: string | null; + /** Key Type */ + key_type?: string | null; /** Last Active */ last_active?: string | null; /** Last Rotation At */ @@ -28376,6 +28382,8 @@ export interface components { key_alias?: string | null; /** Key Name */ key_name?: string | null; + /** Key Type */ + key_type?: string | null; /** Litellm Budget Table */ litellm_budget_table?: unknown | null; /** Max Budget */ @@ -32810,6 +32818,8 @@ export interface components { key_name?: string | null; /** Key Rotation At */ key_rotation_at?: string | null; + /** Key Type */ + key_type?: string | null; /** Last Active */ last_active?: string | null; /** Last Refreshed At */