mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-10 03:28:53 +00:00
fix(liteadmin): detach keys from teams and default to Sonnet 5.5 (#45625)
This commit is contained in:
parent
fe955f5e42
commit
1dbed8e1e2
7 changed files with 153 additions and 4 deletions
|
|
@ -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<typeof fetch>();
|
||||
|
|
@ -117,6 +118,7 @@ function renderWidget(Menu?: ComponentType<{ onLogout: () => void }>) {
|
|||
}
|
||||
|
||||
interface GatewayOptions {
|
||||
models?: ModelGroup[];
|
||||
write?: () => Promise<Response>;
|
||||
read?: () => Promise<Response>;
|
||||
settings?: { target: string; status: number } | ((request: Request) => Promise<Response>);
|
||||
|
|
@ -139,7 +141,7 @@ function gateway(replies: (ModelReply | Promise<ModelReply>)[], 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<ModelReply>)[], 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<Response>();
|
||||
const proposed = { ...toolReply("key_create", keyArguments), content: "Here is the requested change." };
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<typeof fetch>() }));
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<unknown>("/key/update", { ...auth, body: a satisfies Partial<Schemas["UpdateKeyRequest"]> }),
|
||||
),
|
||||
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<unknown>("/key/update", { ...auth, body: a satisfies Partial<Schemas["UpdateKeyRequest"]> }),
|
||||
),
|
||||
operation("delete", "key")(
|
||||
"key_delete",
|
||||
"Delete virtual keys",
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue