From 1dbed8e1e25fa7b90de2fd372dd7aecd9e368350 Mon Sep 17 00:00:00 2001 From: tin-berri Date: Fri, 9 Oct 2026 11:17:53 -0700 Subject: [PATCH] fix(liteadmin): detach keys from teams and default to Sonnet 5.5 (#45625) --- .../liteadmin/LiteAdmin.integration.test.tsx | 54 +++++++++++++++- .../src/components/liteadmin/LiteAdmin.tsx | 5 +- .../src/components/liteadmin/agent.test.ts | 63 ++++++++++++++++++- .../src/components/liteadmin/agent.ts | 10 +++ .../src/components/liteadmin/operations.ts | 7 +++ .../llm_calls/fetch_models.test.tsx | 15 +++++ .../src/components/llm_calls/fetch_models.tsx | 3 + 7 files changed, 153 insertions(+), 4 deletions(-) diff --git a/ui/litellm-dashboard/src/components/liteadmin/LiteAdmin.integration.test.tsx b/ui/litellm-dashboard/src/components/liteadmin/LiteAdmin.integration.test.tsx index 69fd4dcc52a..c2b8805b892 100644 --- a/ui/litellm-dashboard/src/components/liteadmin/LiteAdmin.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/liteadmin/LiteAdmin.integration.test.tsx @@ -11,6 +11,7 @@ import SidebarAccountMenu from "@/components/SidebarAccountMenu/SidebarAccountMe import UserDropdown from "@/components/Navbar/UserDropdown/UserDropdown"; import LiteAdmin, { LiteAdminFrame } from "./LiteAdmin"; import { MAX_INPUT_LENGTH } from "./agent"; +import type { ModelGroup } from "@/components/llm_calls/fetch_models"; const { transport } = vi.hoisted(() => { const transport = vi.fn(); @@ -117,6 +118,7 @@ function renderWidget(Menu?: ComponentType<{ onLogout: () => void }>) { } interface GatewayOptions { + models?: ModelGroup[]; write?: () => Promise; read?: () => Promise; settings?: { target: string; status: number } | ((request: Request) => Promise); @@ -139,7 +141,7 @@ function gateway(replies: (ModelReply | Promise)[], options: Gateway } if (path.endsWith("/model_group/info")) return json({ - data: [ + data: options.models ?? [ { model_group: "a-embedding", mode: "embedding" }, { model_group: "chat-model", mode: "chat" }, ], @@ -162,6 +164,7 @@ function gateway(replies: (ModelReply | Promise)[], options: Gateway return options.write ? options.write() : json({ team_id: "team-1", max_budget: body.max_budget }); if (path.endsWith("/key/generate")) return options.write ? options.write() : json({ key: NEW_KEY, key_alias: "Widget key" }); + if (path.endsWith("/key/update")) return json(body); throw new Error(`Unexpected request: ${request.url}`); }); return requests; @@ -420,6 +423,55 @@ describe("LiteAdmin in the gateway", () => { expect(within(screen.getByLabelText("LiteAdmin conversation")).queryByRole("img")).not.toBeInTheDocument(); }); + it("defaults to eligible Sonnet and preserves a manual choice across catalog refresh", async () => { + const requests = gateway([answer("Default selected."), answer("Manual choice retained.")], { + models: [ + { model_group: "claude-sonnet-5-5", mode: "embedding", providers: ["anthropic"] }, + { model_group: "openrouter/anthropic/claude-sonnet-5.5", mode: "chat", providers: ["openrouter"] }, + { model_group: "chat-model", mode: "chat" }, + ], + }); + const { client } = renderWidget(); + fireEvent.click(await screen.findByRole("button", { name: "LiteAdmin" })); + await waitFor(() => expect(screen.getByPlaceholderText("Ask LiteAdmin…")).toBeEnabled()); + send("Use the default"); + await screen.findByText("Default selected."); + expect(requests.find((request) => request.url.endsWith("/chat/completions"))?.body.model).toBe( + "openrouter/anthropic/claude-sonnet-5.5", + ); + await selectModel(); + await act(async () => client.invalidateQueries({ queryKey: ["liteadmin-models"] })); + send("Keep my selection"); + await screen.findByText("Manual choice retained."); + expect(requests.filter((request) => request.url.endsWith("/chat/completions")).at(-1)?.body.model).toBe( + "chat-model", + ); + }); + + it("reviews the explicit team removal, sends nothing on cancel and detaches on confirmation", async () => { + const key = "a".repeat(64); + const proposal = toolReply("key_detach_from_team", { key }); + const requests = gateway([proposal, proposal, answer("Detached the key.")]); + renderWidget(); + await openWidget(); + send("Remove the key from its team"); + const review = await screen.findByRole("region", { + name: "Remove a virtual key from its team without deleting the key", + }); + expect(review).toHaveTextContent("team id"); + expect(review).toHaveTextContent("null"); + fireEvent.click(within(review).getByRole("button", { name: "Cancel" })); + await screen.findByText("Cancelled"); + expect(requests.filter((request) => request.url.endsWith("/key/update"))).toHaveLength(0); + send("Detach it now"); + fireEvent.click(await screen.findByRole("button", { name: "Confirm change" })); + await screen.findByText("Detached the key."); + expect(screen.getByText("Completed")).toBeInTheDocument(); + expect(requests.filter((request) => request.url.endsWith("/key/update")).map(({ body }) => body)).toEqual([ + { key, team_id: null }, + ]); + }); + it("keeps a single inline action through review, close/reopen and one confirmed write", async () => { const response = deferred(); const proposed = { ...toolReply("key_create", keyArguments), content: "Here is the requested change." }; diff --git a/ui/litellm-dashboard/src/components/liteadmin/LiteAdmin.tsx b/ui/litellm-dashboard/src/components/liteadmin/LiteAdmin.tsx index 39a2394a5fb..38e4334763f 100644 --- a/ui/litellm-dashboard/src/components/liteadmin/LiteAdmin.tsx +++ b/ui/litellm-dashboard/src/components/liteadmin/LiteAdmin.tsx @@ -18,7 +18,7 @@ import { FieldError } from "@/components/ui/field"; import { Skeleton } from "@/components/ui/skeleton"; import { cn } from "@/lib/cva.config"; import { isProxyAdminRole } from "@/utils/roles"; -import { MAX_INPUT_LENGTH, resolveInferenceTarget } from "./agent"; +import { getPreferredLiteAdminModel, MAX_INPUT_LENGTH, resolveInferenceTarget } from "./agent"; import { LiteAdminConversation } from "./LiteAdminConversation"; import { useLiteAdmin, type LiteAdminSession } from "./useLiteAdmin"; @@ -213,7 +213,8 @@ function LiteAdminChat({ session, open, close }: { session: LiteAdminSession; op available.filter((item) => isModeCompatibleWithEndpoint(item.mode, EndpointType.CHAT)), }; const models = useQuery(modelQuery); - const selectedModel = models.data?.some((item) => item.model_group === model) ? model : null; + const preferredModel = model ?? getPreferredLiteAdminModel(models.data ?? []); + const selectedModel = models.data?.some((item) => item.model_group === preferredModel) ? preferredModel : null; const busy = chat.phase !== "idle"; const tooLong = input.trim().length > MAX_INPUT_LENGTH; const hasValidInput = selectedModel && input.trim() && !tooLong; diff --git a/ui/litellm-dashboard/src/components/liteadmin/agent.test.ts b/ui/litellm-dashboard/src/components/liteadmin/agent.test.ts index 772b8525ee1..85c7e58eaf8 100644 --- a/ui/litellm-dashboard/src/components/liteadmin/agent.test.ts +++ b/ui/litellm-dashboard/src/components/liteadmin/agent.test.ts @@ -2,7 +2,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ChatCompletion, ChatCompletionCreateParamsNonStreaming } from "openai/resources/chat/completions"; import { createGatewayClient } from "@/components/llm_calls/gateway_client"; import { registerAuthHeaderNameGetter } from "@/lib/http/runtime"; -import { MAX_INPUT_LENGTH, resolveInferenceTarget, runLiteAdmin, type LiteAdminOptions } from "./agent"; +import { + getPreferredLiteAdminModel, + MAX_INPUT_LENGTH, + resolveInferenceTarget, + runLiteAdmin, + type LiteAdminOptions, +} from "./agent"; import type { ActionResult, LiteAdminAction } from "./operations"; const { managementFetch } = vi.hoisted(() => ({ managementFetch: vi.fn() })); @@ -117,6 +123,7 @@ describe("fixed management operations", () => { ["key_info", "/key/info", "GET", { key: keyHash }, false], ["key_create", "/key/generate", "POST", keyFields, true], ["key_update", "/key/update", "POST", { ...keyFields, key: keyHash }, true], + ["key_detach_from_team", "/key/update", "POST", { key: keyHash }, true], ["key_delete", "/key/delete", "POST", { keys: [keyHash] }, true], ["key_block", "/key/block", "POST", { key: keyHash }, true], ["key_unblock", "/key/unblock", "POST", { key: keyHash }, true], @@ -234,6 +241,27 @@ describe("fixed management operations", () => { }); }); + it.each([true, false])("detaches only the team after approval=%s", async (approved) => { + const input = options(); + input.confirm.mockResolvedValue(approved); + const model = transport([completion([call("key_detach_from_team", { key: keyHash })]), completion()]); + const running = runLiteAdmin(input, model.client); + if (approved) { + await running; + expect(JSON.parse(String(managementFetch.mock.calls[0][1]?.body))).toEqual({ key: keyHash, team_id: null }); + } else { + await expect(running).rejects.toThrow("Action cancelled"); + expect(managementFetch).not.toHaveBeenCalled(); + } + expect(input.confirm).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + name: "key_detach_from_team", + arguments: { key: keyHash, team_id: null }, + destructive: false, + }), + ); + }); + it("bounds team keys and forwards user searches through their actual endpoint parameters", async () => { const model = transport([ completion([ @@ -605,3 +633,36 @@ describe("inference destination", () => { ).toBeNull(); }); }); + +describe("preferred LiteAdmin model", () => { + it("prefers the Anthropic provider over provider names inside other routes", () => { + const fallback = { model_group: "bedrock/anthropic.claude-sonnet-5-5", providers: ["bedrock"] }; + const direct = { model_group: "claude-sonnet-5-5", providers: ["anthropic"] }; + expect(getPreferredLiteAdminModel([fallback, direct])).toBe(direct.model_group); + expect(getPreferredLiteAdminModel([fallback])).toBe(fallback.model_group); + }); + + it.each([ + "sonnet-5-5", + "anthropic/claude-sonnet-5-5", + "bedrock/us.anthropic.claude-sonnet-5-5-v1:0", + "openrouter/anthropic/claude-sonnet-5.5", + "vertex_ai/claude-sonnet-5-5@default", + "azure_ai/claude-sonnet-5-5", + ])("uses an available Sonnet 5.5 route named %s", (model_group) => { + expect(getPreferredLiteAdminModel([{ model_group }])).toBe(model_group); + }); + + it("leaves the choice empty without an identifiable Sonnet 5.5 route", () => { + expect(getPreferredLiteAdminModel([])).toBeNull(); + expect( + getPreferredLiteAdminModel([ + { model_group: "claude-sonnet-5-50", providers: ["anthropic"] }, + { model_group: "claude-sonnet-5-5other", providers: ["anthropic"] }, + { model_group: "notclaude-sonnet-5-5", providers: ["anthropic"] }, + { model_group: "chat-alias", providers: ["anthropic"] }, + { model_group: "claude-sonnet-4-6", providers: ["anthropic"] }, + ]), + ).toBeNull(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/liteadmin/agent.ts b/ui/litellm-dashboard/src/components/liteadmin/agent.ts index e2f652eaf55..0e025c27e3e 100644 --- a/ui/litellm-dashboard/src/components/liteadmin/agent.ts +++ b/ui/litellm-dashboard/src/components/liteadmin/agent.ts @@ -1,10 +1,20 @@ import type OpenAI from "openai"; import type { ChatMessage } from "@/components/chat/types"; +import type { ModelGroup } from "@/components/llm_calls/fetch_models"; import { createGatewayClient } from "@/components/llm_calls/gateway_client"; import { createLiteAdminOperations, type OperationContext } from "./operations"; export const MAX_INPUT_LENGTH = 8_000; +export function getPreferredLiteAdminModel(models: readonly ModelGroup[]): string | null { + const sonnet = models.filter(({ model_group }) => + /(?:^|[/.])(?:claude-)?sonnet-5[-.]5(?:$|[-.:@])/i.test(model_group), + ); + return ( + sonnet.find(({ providers }) => providers?.includes("anthropic"))?.model_group ?? sonnet[0]?.model_group ?? null + ); +} + const SYSTEM_PROMPT = `You are LiteAdmin, the assistant for a LiteLLM gateway administrator. Use the provided tools for gateway facts and requested changes. Look up resource identifiers before making changes. Never invent identifiers or claim success without a successful tool result. Writes require the administrator to review and approve their exact arguments in the interface. diff --git a/ui/litellm-dashboard/src/components/liteadmin/operations.ts b/ui/litellm-dashboard/src/components/liteadmin/operations.ts index 6983503c52b..706b0c68be8 100644 --- a/ui/litellm-dashboard/src/components/liteadmin/operations.ts +++ b/ui/litellm-dashboard/src/components/liteadmin/operations.ts @@ -206,6 +206,13 @@ export function createLiteAdminOperations(context: OperationContext) { operation("write", "key")("key_update", "Update a virtual key", object({ key: hash, ...keyFields }), (a) => apiClient.post("/key/update", { ...auth, body: a satisfies Partial }), ), + operation("write", "key", { team_id: null })( + "key_detach_from_team", + "Remove a virtual key from its team without deleting the key", + object({ key: hash }), + (a) => + apiClient.post("/key/update", { ...auth, body: a satisfies Partial }), + ), operation("delete", "key")( "key_delete", "Delete virtual keys", diff --git a/ui/litellm-dashboard/src/components/llm_calls/fetch_models.test.tsx b/ui/litellm-dashboard/src/components/llm_calls/fetch_models.test.tsx index c65ece4ca1c..41c673ddd2c 100644 --- a/ui/litellm-dashboard/src/components/llm_calls/fetch_models.test.tsx +++ b/ui/litellm-dashboard/src/components/llm_calls/fetch_models.test.tsx @@ -38,6 +38,21 @@ describe("fetchAvailableModels", () => { vi.clearAllMocks(); }); + it("retains provider identity without inventing it for an unknown route", async () => { + modelHubCallMock.mockResolvedValue({ + data: [ + { model_group: "direct", providers: ["anthropic"] }, + { model_group: "mixed", providers: ["bedrock", "anthropic"] }, + { model_group: "unknown", providers: null }, + ], + }); + expect(await fetchAvailableModels("token")).toEqual([ + { model_group: "direct", providers: ["anthropic"] }, + { model_group: "mixed", providers: ["bedrock", "anthropic"] }, + { model_group: "unknown" }, + ]); + }); + it("carries the reasoning capabilities the model hub reports for each group", async () => { modelHubCallMock.mockResolvedValue({ data: [ diff --git a/ui/litellm-dashboard/src/components/llm_calls/fetch_models.tsx b/ui/litellm-dashboard/src/components/llm_calls/fetch_models.tsx index b3df5c9bf65..d38db631c6e 100644 --- a/ui/litellm-dashboard/src/components/llm_calls/fetch_models.tsx +++ b/ui/litellm-dashboard/src/components/llm_calls/fetch_models.tsx @@ -5,6 +5,7 @@ import { modelAvailableCall, modelHubCall } from "@/components/networking"; export interface ModelGroup { model_group: string; + providers?: string[]; mode?: string; supports_reasoning?: boolean; supports_fast_mode?: boolean; @@ -15,6 +16,7 @@ interface AvailableModel { model_group?: string | null; model_name?: string | null; id?: string | null; + providers?: string[] | null; mode?: string | null; supports_reasoning?: boolean | null; supports_fast_mode?: boolean | null; @@ -25,6 +27,7 @@ const toModelGroup = (item: AvailableModel): ModelGroup => { const groupName = (item.model_group || item.id || item.model_name) ?? ""; return { model_group: groupName, + ...(item.providers && { providers: item.providers }), ...(item.mode && { mode: item.mode }), ...(item.supports_reasoning === true && { supports_reasoning: true }), ...(item.supports_fast_mode === true && { supports_fast_mode: true }),