fix: stop a cleared Team field from blocking personal key creation

Clearing the Team combobox in the Create Key modal left team_id set to an
empty string, so /key/generate treated the request as team key generation
and failed with a team-not-found error for non-admin members.

TeamDropdown now emits null on clear, and GenerateKeyRequest normalizes an
empty team_id to None so the request runs the personal key path.
This commit is contained in:
ryan-crabbe-berri 2026-09-01 15:17:00 -07:00
parent bad55da9bf
commit 55d638412b
4 changed files with 103 additions and 2 deletions

View file

@ -1216,6 +1216,13 @@ class GenerateKeyRequest(KeyRequestBase):
organization_id: str | None = None
project_id: str | None = None
@field_validator("team_id", mode="before")
@classmethod
def treat_cleared_team_id_as_unset(cls, v: object) -> object:
if v == "":
return None
return v
class GenerateKeyResponse(KeyRequestBase):
key: str

View file

@ -17489,3 +17489,49 @@ async def test_check_project_key_limits_still_rejects_real_model_outside_project
assert exc_info.value.status_code == 400
assert "Model 'gpt-5.4-mini' not in project's allowed models" in exc_info.value.detail["error"]
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
from litellm.proxy.management_endpoints.key_management_endpoints import (
_is_team_key,
)
cleared = GenerateKeyRequest(team_id="")
assert cleared.team_id is None
assert _is_team_key(data=cleared) is False
assert RegenerateKeyRequest(team_id="").team_id is None
assert GenerateKeyRequest(team_id="team-1").team_id == "team-1"
def test_key_generation_check_blank_team_id_uses_personal_permissions(monkeypatch):
"""key_generation_check with team_id="" must take the personal-key path instead
of failing the team lookup with "Unable to find team object" (LIT-3925)."""
from litellm.proxy._types import KeyManagementRoutes
from litellm.proxy.management_endpoints.key_management_endpoints import (
key_generation_check,
)
monkeypatch.setattr(
litellm,
"key_generation_settings",
{
"team_key_generation": {"allowed_team_member_roles": ["admin"]},
"personal_key_generation": {"allowed_user_roles": ["proxy_admin", "internal_user"]},
},
)
assert (
key_generation_check(
team_table=None,
user_api_key_dict=UserAPIKeyAuth(
user_role=LitellmUserRoles.INTERNAL_USER,
api_key="sk-alice",
user_id="alice",
),
data=GenerateKeyRequest(key_alias="personal", team_id=""),
route=KeyManagementRoutes.KEY_GENERATE,
)
is True
)

View file

@ -0,0 +1,48 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { chooseSelectOption } from "../../../tests/test-utils";
import type { Team } from "../key_team_helpers/key_list";
import TeamDropdown from "./team_dropdown";
const TEAMS = [
{ team_id: "team-1", team_alias: "Alpha Team" },
{ team_id: "team-2", team_alias: "Beta Team" },
] as unknown as Team[];
vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({
useInfiniteTeams: () => ({
data: { pages: [{ teams: TEAMS }] },
fetchNextPage: vi.fn(),
hasNextPage: false,
isFetchingNextPage: false,
isLoading: false,
}),
}));
describe("TeamDropdown", () => {
it("emits the picked team's id and full object", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
const onTeamSelect = vi.fn();
render(<TeamDropdown onChange={onChange} onTeamSelect={onTeamSelect} />);
await chooseSelectOption(user, screen.getByRole("combobox"), /^Beta Team/);
expect(onChange).toHaveBeenCalledWith("team-2");
expect(onTeamSelect).toHaveBeenCalledWith(TEAMS[1]);
});
it("emits null, never the empty string, when the selection is cleared", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
const onTeamSelect = vi.fn();
render(<TeamDropdown value="team-1" onChange={onChange} onTeamSelect={onTeamSelect} />);
await user.click(document.querySelector('[data-slot="combobox-clear"]') as HTMLElement);
expect(onChange).toHaveBeenCalledWith(null);
expect(onTeamSelect).toHaveBeenCalledWith(null);
});
});

View file

@ -5,7 +5,7 @@ import { Team } from "../key_team_helpers/key_list";
interface TeamDropdownProps {
value?: string;
onChange?: (value: string) => void;
onChange?: (value: string | null) => void;
/** Callback with the full Team object (or null on clear). */
onTeamSelect?: (team: Team | null) => void;
disabled?: boolean;
@ -47,7 +47,7 @@ const TeamDropdown: React.FC<TeamDropdownProps> = ({
}, [data]);
const handleChange = (teamId: string) => {
onChange?.(teamId);
onChange?.(teamId || null);
if (onTeamSelect) {
onTeamSelect(teamId ? teams.find((t) => t.team_id === teamId) ?? null : null);
}