fix(keys): persist key_type so the UI shows correct key scope instead of "All Proxy Models" (#33115)

* fix(ui): derive key model scope so SCIM/management/read-only keys stop showing 'All Proxy Models'

key_type is not persisted on a key (the proxy maps it to allowed_routes and
drops it), so the keys tables only inspected the models list and rendered
'All Proxy Models' for any key with an empty models array, including SCIM,
Management and Read-only keys that cannot call a single model.

Add deriveKeyModelScope(allowed_routes) and render 'No model access' with a
scope tooltip for those recognized scopes; unrestricted, AI-API and custom
keys keep the existing model-list rendering.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* refactor(ui): move key_scope helper to components root

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat(keys): persist key_type on virtual keys so the UI reads scope directly

Add a nullable key_type column to LiteLLM_VerificationToken (root, proxy,
and proxy-extras schemas plus an additive migration) and stop dropping the
value in handle_key_type, so management/read_only/llm_api/default keys store
their type alongside the derived allowed_routes. Surface it on the key read
and create response models. The dashboard now prefers the persisted key_type
for the no-inference buckets and keeps the allowed_routes derivation as the
fallback for keys created before the column existed (key_type null), so no
backfill is required.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* style(keys): use PEP604 X | None for new key_type annotations to satisfy ruff UP045 budget

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(keys): add key_type column to LiteLLM_DeletedVerificationToken

The deleted-token archive model inherits key_type from the verification
token, so regenerate/delete flows write key_type into
LiteLLM_DeletedVerificationToken. Add the column (all schemas + migration)
so the archive insert does not fail with FieldNotFoundError.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(migrations): regenerate key_type migration via runbook (canonical ADD COLUMN)

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: ryan <ryan@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot] 2026-07-13 18:08:43 -07:00 committed by GitHub
parent 384bbf7fc2
commit 001457af8b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 243 additions and 6 deletions

View file

@ -0,0 +1,6 @@
-- AlterTable
ALTER TABLE "LiteLLM_DeletedVerificationToken" ADD COLUMN "key_type" TEXT;
-- AlterTable
ALTER TABLE "LiteLLM_VerificationToken" ADD COLUMN "key_type" TEXT;

View file

@ -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("{}")

View file

@ -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 = {}

View file

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

View file

@ -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 [],

View file

@ -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("{}")

View file

@ -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("{}")

View file

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

View file

@ -327,7 +327,13 @@ export const getKeyTableColumns = ({
header: "Models",
size: 220,
enableSorting: false,
cell: (info) => <ModelsCell models={info.getValue() as string[] | null | undefined} />,
cell: (info) => (
<ModelsCell
models={info.getValue() as string[] | null | undefined}
allowedRoutes={info.row.original.allowed_routes}
keyType={info.row.original.key_type}
/>
),
},
{
id: "rate_limits",

View file

@ -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 });
});
});

View file

@ -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;
};

View file

@ -47,6 +47,7 @@ export interface KeyResponse {
budget_reset_at: string;
allowed_cache_controls: string[];
allowed_routes: string[];
key_type: string | null;
permissions: Record<string, unknown>;
model_spend: Record<string, number>;
model_max_budget: Record<string, number>;

View file

@ -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(<ModelsCell models={[]} allowedRoutes={["/scim/*"]} />);
expect(screen.getByText("No model access")).toBeInTheDocument();
expect(screen.queryByText("All Proxy Models")).not.toBeInTheDocument();
rerender(<ModelsCell models={[]} allowedRoutes={["management_routes"]} />);
expect(screen.getByText("No model access")).toBeInTheDocument();
rerender(<ModelsCell models={[]} allowedRoutes={["info_routes"]} />);
expect(screen.getByText("No model access")).toBeInTheDocument();
});
it("still shows 'All Proxy Models' for empty models when the key is not scope-restricted", () => {
render(<ModelsCell models={[]} allowedRoutes={["llm_api_routes"]} />);
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(<ModelsCell models={[]} allowedRoutes={[]} keyType="management" />);
expect(screen.getByText("No model access")).toBeInTheDocument();
expect(screen.queryByText("All Proxy Models")).not.toBeInTheDocument();
rerender(<ModelsCell models={[]} allowedRoutes={[]} keyType="read_only" />);
expect(screen.getByText("No model access")).toBeInTheDocument();
});
it("renders every model with no overflow badge when at or below the limit", () => {
render(<ModelsCell models={["gpt-4o", "claude-sonnet-4-5", "o3-mini"]} maxVisible={3} />);
expect(screen.getByText("gpt-4o")).toBeInTheDocument();

View file

@ -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 (
<CellTooltip
content={`Scoped to ${scope.label} routes; this key cannot call any models`}
trigger={
<Badge variant="secondary" className="cursor-default">
No model access
</Badge>
}
/>
);
}
return <Badge variant="secondary">All Proxy Models</Badge>;
}

View file

@ -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 ? (
<Tooltip title={`Scoped to ${scope.label} routes; this key cannot call any models`}>
<Badge size="xs" className="mb-1" color="gray">
<Text>No model access</Text>
</Badge>
</Tooltip>
) : (
<Badge size="xs" className="mb-1" color="red">
<Text>All Proxy Models</Text>
</Badge>
);
return (
<div className="flex flex-col py-2">
{Array.isArray(models) ? (
<div className="flex flex-col">
{models.length === 0 ? (
<Badge size="xs" className="mb-1" color="red">
<Text>All Proxy Models</Text>
</Badge>
emptyModelsBadge
) : (
<>
<div className="flex items-start">

View file

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