fix(liteadmin): detach keys from teams and default to Sonnet 5.5 (#45625)

This commit is contained in:
tin-berri 2026-10-09 11:17:53 -07:00 • committed by GitHub
parent fe955f5e42
commit 1dbed8e1e2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 153 additions and 4 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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