mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-10 03:28:53 +00:00
feat(ui): let admins find, call, and test decision models from the Admin UI (#45654)
* feat(ui): announce decision model support in Models + Endpoints * feat(ui): make decision models findable, callable, and testable from the Admin UI Add Model provider search now matches decision model names and shows a note on where to call a decision model. The banner links to the docs and opens Add Model. The System One playground defaults to /v1/systemone with a picker of the proxy's decision models, and the model hub shows a /v1/systemone usage example for evaluation models. * fix(ui): keep the admin signed in when a typed virtual key cannot list decision models The System One playground looked up a typed virtual key's decision models through the dashboard's shared request client, so an expired key signed the admin out, and typing a key by hand sent one lookup per keystroke. The lookup now uses the same client as Send, against the same proxy, and waits for typing to stop * fix(ui): hide decision model Playground links from view-only sessions A proxy admin viewer cannot open the Playground, so the Models + Endpoints decision models banner and the AI Hub's Try it in the Playground link sent them to an Access Denied page. Hide both for view-only sessions. * fix(ui): send the AI Hub decision example's key in the proxy's configured header A proxy with litellm_key_header_name set reads keys only from that header, so the example's hardcoded Authorization header was rejected. Use the header name the dashboard already reads from the session. * fix(ui): stop the decision model notice from ruling out /chat/completions for dual-mode models * fix(ui): point only users who can add models at Add Model in the decision models banner --------- Co-authored-by: moyai-devin-berriai[bot] <336287033+moyai-devin-berriai[bot]@users.noreply.github.com> Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
This commit is contained in:
parent
8facf4aca2
commit
e2276ac2b9
29 changed files with 1135 additions and 162 deletions
|
|
@ -6,9 +6,17 @@ import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
|||
import { isAdminRole } from "@/utils/roles";
|
||||
|
||||
export default function ModelHubTablePage() {
|
||||
const { accessToken, userRole, premiumUser } = useAuthorized();
|
||||
const { accessToken, userRole, premiumUser, isViewOnly } = useAuthorized();
|
||||
if (!isAdminRole(userRole)) {
|
||||
return <PublicModelHub accessToken={accessToken} isEmbedded={true} />;
|
||||
}
|
||||
return <ModelHubTable accessToken={accessToken} publicPage={false} premiumUser={premiumUser} userRole={userRole} />;
|
||||
return (
|
||||
<ModelHubTable
|
||||
accessToken={accessToken}
|
||||
publicPage={false}
|
||||
premiumUser={premiumUser}
|
||||
userRole={userRole}
|
||||
canOpenPlayground={!isViewOnly}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ vi.mock("./panels/ModelRetrySettingsPanel", () => ({ default: () => <div data-te
|
|||
vi.mock("./panels/ModelGroupAliasPanel", () => ({ default: () => <div data-testid="panel-alias" /> }));
|
||||
vi.mock("./panels/PriceDataPanel", () => ({ default: () => <div data-testid="panel-price" /> }));
|
||||
vi.mock("./panels/AccessGroupBudgetsPanel", () => ({ default: () => <div data-testid="panel-budgets" /> }));
|
||||
vi.mock("@/components/molecules/cost_optimization_feedback_banner", () => ({ default: () => null }));
|
||||
vi.mock("@/components/molecules/DecisionModelsBanner", () => ({ default: () => null }));
|
||||
vi.mock("@/components/model_info_view", () => ({
|
||||
default: ({ modelId }: { modelId: string }) => <div data-testid="model-info">model:{modelId}</div>,
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -21,7 +21,17 @@ vi.mock("./detailNavigation", () => ({
|
|||
useModelDetailRouting: () => ({ ...detailState, close: vi.fn(), openModel: vi.fn(), openTeam: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/molecules/cost_optimization_feedback_banner", () => ({ default: () => null }));
|
||||
vi.mock("@/components/molecules/DecisionModelsBanner", () => ({
|
||||
default: ({ onAddModel }: { onAddModel?: () => void }) => (
|
||||
<section aria-label="Decision models">
|
||||
{onAddModel && (
|
||||
<button type="button" onClick={onAddModel}>
|
||||
Add a decision model
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/components/model_info_view", () => ({
|
||||
default: ({ modelId }: { modelId: string }) => <div data-testid="model-info">model:{modelId}</div>,
|
||||
}));
|
||||
|
|
@ -97,6 +107,27 @@ describe("ModelsAndEndpointsPage", () => {
|
|||
expect(screen.queryByTestId("panel-all-models")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the Add Model tab from the decision models banner", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
await user.click(screen.getByRole("button", { name: "Add a decision model" }));
|
||||
expect(screen.getByTestId("panel-add")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("panel-all-models")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not offer the banner's Add a decision model to a session that cannot add models", () => {
|
||||
mockUseAuthorized.mockReturnValue(NON_ADMIN);
|
||||
renderPage();
|
||||
expect(screen.getByRole("region", { name: "Decision models" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Add a decision model" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show a view-only session the decision models banner, whose Playground it cannot open", () => {
|
||||
mockUseAuthorized.mockReturnValue(VIEW_ONLY_ADMIN);
|
||||
renderPage();
|
||||
expect(screen.queryByRole("region", { name: "Decision models" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the model detail overlay from the ?model drill-in and hides the tabs", () => {
|
||||
detailState.modelId = "abc-123";
|
||||
renderPage();
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings"
|
|||
import { all_admin_roles, internalUserRoles } from "@/utils/roles";
|
||||
import { autoRouterCreationScope, canCreateModels } from "@/utils/modelPermissions";
|
||||
import BetaBadge from "@/components/BetaBadge";
|
||||
import CostOptimizationFeedbackBanner from "@/components/molecules/cost_optimization_feedback_banner";
|
||||
import DecisionModelsBanner from "@/components/molecules/DecisionModelsBanner";
|
||||
import ModelInfoView from "@/components/model_info_view";
|
||||
import TeamInfoView from "@/components/team/TeamInfo";
|
||||
import { useModelDetailRouting } from "@/app/(dashboard)/models-and-endpoints/detailNavigation";
|
||||
|
|
@ -178,7 +178,9 @@ export default function ModelsAndEndpointsPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<CostOptimizationFeedbackBanner />
|
||||
{!isViewOnly && (
|
||||
<DecisionModelsBanner onAddModel={canCreate && !modelId ? () => setActiveKey("add") : undefined} />
|
||||
)}
|
||||
|
||||
{modelId ? (
|
||||
<ModelInfoView
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useSyncExternalStore } from "react";
|
||||
import { act, renderWithProviders, screen, waitFor } from "../../../../../tests/test-utils";
|
||||
import { act, renderWithProviders, screen, waitFor, within } from "../../../../../tests/test-utils";
|
||||
import userEvent, { PointerEventsCheckLevel } from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import AddModelPanel from "./AddModelPanel";
|
||||
|
|
@ -72,6 +72,13 @@ vi.mock("@/app/(dashboard)/hooks/providers/useProviderFields", () => ({
|
|||
default_model_placeholder: "claude-3-opus",
|
||||
credential_fields: [{ key: "api_key", label: "API Key", field_type: "password", required: false }],
|
||||
},
|
||||
{
|
||||
provider: "TypeSafe",
|
||||
provider_display_name: "TypeSafe",
|
||||
litellm_provider: "typesafe",
|
||||
default_model_placeholder: "jev-latest",
|
||||
credential_fields: [{ key: "api_key", label: "API Key", field_type: "password", required: false }],
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
|
@ -538,3 +545,61 @@ describe("AddModelPanel behaviours the removed Advanced Settings form instance n
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("AddModelPanel decision models", () => {
|
||||
const DECISION_COST_MAP = {
|
||||
"typesafe/jev-latest": { litellm_provider: "typesafe", mode: "evaluation" },
|
||||
"gpt-4o-2024-08-06": { litellm_provider: "openai", mode: "chat" },
|
||||
"gpt-6-luna": { litellm_provider: "openai", mode: "chat", supported_endpoints: ["/v1/decisions"] },
|
||||
"claude-sonnet-4-5": { litellm_provider: "anthropic", mode: "chat" },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockUseModelCostMap.mockReturnValue({ data: DECISION_COST_MAP });
|
||||
mockPtuEnabled.mockReturnValue(false);
|
||||
mockAuthorized.mockReturnValue(PROXY_ADMIN);
|
||||
});
|
||||
|
||||
const decisionNotice = () => screen.queryByRole("note", { name: "Decision model notice" });
|
||||
|
||||
it("finds a provider by the name of one of its decision models", async () => {
|
||||
const { user } = await setup();
|
||||
await user.type(screen.getByRole("combobox", { name: /provider/i }), "jev");
|
||||
|
||||
expect(await screen.findByRole("option", { name: /TypeSafe/ })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("option", { name: /Anthropic/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("points a decision-only provider at the decision routes, the docs, and the playground", async () => {
|
||||
const { user } = await setup();
|
||||
await user.type(screen.getByRole("combobox", { name: /provider/i }), "TypeSafe");
|
||||
await user.click(await screen.findByRole("option", { name: /TypeSafe/ }));
|
||||
|
||||
const note = await screen.findByRole("note", { name: "Decision model notice" });
|
||||
expect(note).toHaveTextContent("/v1/decisions");
|
||||
expect(within(note).getByRole("link", { name: "How to call decision models" })).toHaveAttribute(
|
||||
"href",
|
||||
"https://docs.litellm.ai/docs/decisions",
|
||||
);
|
||||
expect(within(note).getByRole("link", { name: "test it in the System One playground" })).toHaveAttribute(
|
||||
"href",
|
||||
"/ui/playground?tab=system-one",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the notice on a chat provider only once one of its decision models is picked", async () => {
|
||||
const { user } = await setup();
|
||||
await user.click(screen.getByRole("combobox", { name: /provider/i }));
|
||||
await user.click(await screen.findByRole("option", { name: /OpenAI/ }));
|
||||
await user.click(await screen.findByPlaceholderText("Select models"));
|
||||
await user.click(await screen.findByText("gpt-4o-2024-08-06"));
|
||||
expect(decisionNotice()).not.toBeInTheDocument();
|
||||
|
||||
await user.click(await screen.findByText("gpt-6-luna"));
|
||||
|
||||
const note = await screen.findByRole("note", { name: "Decision model notice" });
|
||||
expect(note).toHaveTextContent("/v1/decisions");
|
||||
expect(note).not.toHaveTextContent("/chat/completions");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { useModelCostMap } from "@/app/(dashboard)/hooks/models/useModelCostMap"
|
|||
import { useCredentials } from "@/app/(dashboard)/hooks/credentials/useCredentials";
|
||||
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { buildDecisionCatalog } from "@/lib/decisionModels";
|
||||
|
||||
const INITIAL_VALUES: MountedFormValues = { litellm_credential_name: null };
|
||||
|
||||
|
|
@ -32,6 +33,7 @@ export default function AddModelPanel() {
|
|||
() => (typeof pickedProvider === "string" ? getProviderModels(pickedProvider, modelCostMapData) : []),
|
||||
[pickedProvider, modelCostMapData],
|
||||
);
|
||||
const decisionCatalog = useMemo(() => buildDecisionCatalog(modelCostMapData), [modelCostMapData]);
|
||||
const [showAdvancedSettings, setShowAdvancedSettings] = useState(false);
|
||||
|
||||
const refresh = () => queryClient.invalidateQueries({ queryKey: ["models", "list"] });
|
||||
|
|
@ -66,6 +68,7 @@ export default function AddModelPanel() {
|
|||
setShowAdvancedSettings={setShowAdvancedSettings}
|
||||
teams={teams ?? null}
|
||||
credentials={credentialsResponse?.credentials || []}
|
||||
decisionCatalog={decisionCatalog}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|||
import SystemOneUI from "./SystemOneUI";
|
||||
import type { SystemOneResponse } from "./lib/schemas";
|
||||
|
||||
interface ModelGroupInfo {
|
||||
model_group: string;
|
||||
mode: string;
|
||||
}
|
||||
|
||||
const DECISION_AND_CHAT_MODELS: ModelGroupInfo[] = [
|
||||
{ model_group: "gpt-5.5", mode: "chat" },
|
||||
{ model_group: "jev-latest", mode: "evaluation" },
|
||||
{ model_group: "pplx-decider", mode: "evaluation" },
|
||||
];
|
||||
|
||||
const responseBody: SystemOneResponse = {
|
||||
model: "jev-1.13.0",
|
||||
answers: {
|
||||
|
|
@ -32,6 +43,21 @@ const render = (ui: ReactElement) =>
|
|||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
const modelGroupInfoResponse = (groups: ModelGroupInfo[]) =>
|
||||
({ ok: true, status: 200, text: async () => JSON.stringify({ data: groups }) }) as Response;
|
||||
|
||||
const EXPIRED_KEY_RESPONSE = {
|
||||
ok: false,
|
||||
status: 401,
|
||||
text: async () =>
|
||||
JSON.stringify({ error: { message: "Authentication Error - Expired Key.", type: "expired_key", code: "401" } }),
|
||||
} as Response;
|
||||
|
||||
const isModelLookup = (input: RequestInfo | URL) => String(input).endsWith("/model_group/info");
|
||||
|
||||
const lookupAuthHeaders = (calls: Parameters<typeof fetch>[]) =>
|
||||
calls.map(([, init]) => Object.values((init?.headers ?? {}) as Record<string, string>));
|
||||
|
||||
const createResponse = (body: SystemOneResponse, status = 200, errorText = "") =>
|
||||
({
|
||||
ok: status >= 200 && status < 300,
|
||||
|
|
@ -41,17 +67,23 @@ const createResponse = (body: SystemOneResponse, status = 200, errorText = "") =
|
|||
|
||||
describe("SystemOneUI integration", () => {
|
||||
const mockFetch = vi.fn<typeof fetch>();
|
||||
const mockModelLookup = vi.fn<typeof fetch>();
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
mockFetch.mockReset();
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
mockModelLookup.mockReset();
|
||||
vi.stubGlobal("fetch", (input: RequestInfo | URL, init?: RequestInit) =>
|
||||
isModelLookup(input) ? mockModelLookup(input, init) : mockFetch(input, init),
|
||||
);
|
||||
mockFetch.mockResolvedValue(createResponse(responseBody));
|
||||
mockModelLookup.mockResolvedValue(modelGroupInfoResponse([]));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
sessionStorage.clear();
|
||||
document.cookie = "token=; path=/; max-age=0";
|
||||
});
|
||||
|
||||
it("restores the example after the request is edited", async () => {
|
||||
|
|
@ -80,6 +112,10 @@ describe("SystemOneUI integration", () => {
|
|||
expect(screen.getByRole("note", { name: "Decision endpoint notice" })).toHaveTextContent(
|
||||
"omit model to use the proxy's configured default.",
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "How to call /v1/decisions and /v1/systemone" })).toHaveAttribute(
|
||||
"href",
|
||||
"https://docs.litellm.ai/docs/decisions",
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "Give us feedback on what you want for decision models" })).toHaveAttribute(
|
||||
"href",
|
||||
"https://github.com/BerriAI/litellm/discussions/44231",
|
||||
|
|
@ -87,6 +123,106 @@ describe("SystemOneUI integration", () => {
|
|||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens on /v1/systemone with the first decision model the key can call", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockModelLookup.mockResolvedValue(modelGroupInfoResponse(DECISION_AND_CHAT_MODELS));
|
||||
render(<SystemOneUI accessToken="session-key" />);
|
||||
|
||||
expect(await screen.findByRole("combobox", { name: "Decision model" })).toHaveValue("jev-latest");
|
||||
expect(lookupAuthHeaders(mockModelLookup.mock.calls)).toEqual([expect.arrayContaining(["Bearer session-key"])]);
|
||||
await user.click(screen.getByRole("button", { name: "Send" }));
|
||||
|
||||
expect(await screen.findByText("Selected choice")).toBeInTheDocument();
|
||||
expect(mockFetch.mock.calls[0]?.[0]).toMatch(/\/v1\/systemone$/);
|
||||
expect(mockFetch.mock.calls[0]?.[0]).not.toMatch(/typesafe/);
|
||||
expect(JSON.parse(mockFetch.mock.calls[0]?.[1]?.body as string)).toMatchObject({
|
||||
model: "jev-latest",
|
||||
questions: { has_repro_steps: { type: "noul" } },
|
||||
});
|
||||
});
|
||||
|
||||
it("offers only decision models and writes the picked one into the request", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockModelLookup.mockResolvedValue(modelGroupInfoResponse(DECISION_AND_CHAT_MODELS));
|
||||
render(<SystemOneUI accessToken="session-key" />);
|
||||
const picker = await screen.findByRole("combobox", { name: "Decision model" });
|
||||
|
||||
await user.clear(picker);
|
||||
await user.type(picker, "p");
|
||||
expect(await screen.findByRole("option", { name: "pplx-decider" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("option", { name: "gpt-5.5" })).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole("option", { name: "pplx-decider" }));
|
||||
|
||||
const editor = screen.getByRole("textbox", { name: "System One JSON payload" });
|
||||
expect(JSON.parse((editor as HTMLTextAreaElement).value)).toMatchObject({
|
||||
model: "pplx-decider",
|
||||
questions: { has_repro_steps: { type: "noul" } },
|
||||
});
|
||||
await user.click(screen.getByRole("button", { name: "Send" }));
|
||||
expect(await screen.findByText("Selected choice")).toBeInTheDocument();
|
||||
expect(JSON.parse(mockFetch.mock.calls[0]?.[1]?.body as string).model).toBe("pplx-decider");
|
||||
});
|
||||
|
||||
it("links to Add Model when the key cannot call any decision model", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SystemOneUI accessToken="session-key" />);
|
||||
screen.getByRole("combobox", { name: "Decision endpoint" }).focus();
|
||||
await user.keyboard("{ArrowDown}");
|
||||
await user.click(await screen.findByRole("option", { name: "System One · /v1/systemone" }));
|
||||
|
||||
expect(await screen.findByRole("link", { name: "Add a decision model" })).toHaveAttribute(
|
||||
"href",
|
||||
"/ui/models-and-endpoints",
|
||||
);
|
||||
expect(screen.getByRole("combobox", { name: "Decision model" })).toHaveAttribute(
|
||||
"placeholder",
|
||||
"No decision models yet",
|
||||
);
|
||||
});
|
||||
|
||||
it("looks up a typed virtual key's decision models once, after typing settles", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockModelLookup.mockResolvedValue(modelGroupInfoResponse(DECISION_AND_CHAT_MODELS));
|
||||
render(<SystemOneUI accessToken={null} disabledPersonalKeyCreation />);
|
||||
|
||||
await user.type(screen.getByLabelText("Virtual Key", { exact: true }), "sk-typed-key");
|
||||
|
||||
expect(await screen.findByRole("combobox", { name: "Decision model" })).toHaveValue("jev-latest");
|
||||
expect(lookupAuthHeaders(mockModelLookup.mock.calls)).toEqual([expect.arrayContaining(["Bearer sk-typed-key"])]);
|
||||
});
|
||||
|
||||
it("keeps the admin signed in when a typed virtual key has expired", async () => {
|
||||
document.cookie = "token=ui-session; path=/";
|
||||
mockModelLookup.mockResolvedValue(EXPIRED_KEY_RESPONSE);
|
||||
render(<SystemOneUI accessToken={null} disabledPersonalKeyCreation />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Virtual Key", { exact: true }), { target: { value: "sk-expired" } });
|
||||
|
||||
await waitFor(() => expect(mockModelLookup).toHaveBeenCalled());
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
expect(document.cookie).toContain("token=ui-session");
|
||||
expect(screen.getByRole("combobox", { name: "Decision endpoint" })).toHaveTextContent("/typesafe/v1/systemone");
|
||||
});
|
||||
|
||||
it("keeps an edited TypeSafe request in place when decision models load afterwards", async () => {
|
||||
const models = Promise.withResolvers<Response>();
|
||||
mockModelLookup.mockReturnValue(models.promise);
|
||||
render(<SystemOneUI accessToken="session-key" />);
|
||||
const editor = screen.getByRole("textbox", { name: "System One JSON payload" });
|
||||
const draft = JSON.stringify({ state: "edited", questions: { q: { type: "noul", instructions: "Yes?" } } });
|
||||
fireEvent.change(editor, { target: { value: draft } });
|
||||
|
||||
await act(async () => {
|
||||
models.resolve(modelGroupInfoResponse(DECISION_AND_CHAT_MODELS));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
expect(editor).toHaveValue(draft);
|
||||
expect(screen.queryByRole("combobox", { name: "Decision model" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows invalid JSON and disables Send", async () => {
|
||||
render(<SystemOneUI accessToken="session-key" />);
|
||||
|
||||
|
|
|
|||
|
|
@ -4,24 +4,27 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { SearchSelect } from "@/components/shared/SearchSelect";
|
||||
import { DECISIONS_DOCS_URL } from "@/lib/decisionModels";
|
||||
import { uiHref } from "@/utils/uiHref";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Code, Info, LoaderCircle, RotateCcw, Send } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { makeSystemOneRequest } from "../../llm_calls/system_one";
|
||||
import { DECISIONS_EXAMPLE, SYSTEM_ONE_EXAMPLE } from "./lib/example";
|
||||
import { PLACEHOLDER_DECISION_MODEL, SYSTEM_ONE_EXAMPLE, decisionsExample } from "./lib/example";
|
||||
import { payloadModel, withPayloadModel } from "./lib/payloadModel";
|
||||
import type { DecisionEndpoint, PlaygroundRequest } from "./lib/schemas";
|
||||
import JsonEditor from "./JsonEditor";
|
||||
import QuestionBreakdown from "./QuestionBreakdown";
|
||||
import ResponseView from "./ResponseView";
|
||||
import { validateSystemOnePayload } from "./lib/validatePayload";
|
||||
import { useDecisionModels, type ApiKeySource } from "./useDecisionModels";
|
||||
|
||||
interface SystemOneUIProps {
|
||||
accessToken: string | null;
|
||||
disabledPersonalKeyCreation?: boolean;
|
||||
}
|
||||
|
||||
type ApiKeySource = "session" | "custom";
|
||||
|
||||
interface SystemOneSendVariables {
|
||||
payload: PlaygroundRequest;
|
||||
endpoint: DecisionEndpoint;
|
||||
|
|
@ -30,7 +33,6 @@ interface SystemOneSendVariables {
|
|||
}
|
||||
|
||||
const EXAMPLE_PAYLOAD = JSON.stringify(SYSTEM_ONE_EXAMPLE, null, 2);
|
||||
const DECISIONS_EXAMPLE_PAYLOAD = JSON.stringify(DECISIONS_EXAMPLE, null, 2);
|
||||
const DECISION_MODELS_DISCUSSION_URL = "https://github.com/BerriAI/litellm/discussions/44231";
|
||||
|
||||
function getCustomProxyBaseUrl(): string | undefined {
|
||||
|
|
@ -40,16 +42,23 @@ function getCustomProxyBaseUrl(): string | undefined {
|
|||
export default function SystemOneUI({ accessToken, disabledPersonalKeyCreation = false }: SystemOneUIProps) {
|
||||
const [apiKeySource, setApiKeySource] = useState<ApiKeySource>(disabledPersonalKeyCreation ? "custom" : "session");
|
||||
const [customApiKey, setCustomApiKey] = useState("");
|
||||
const [endpoint, setEndpoint] = useState<DecisionEndpoint>("/typesafe/v1/systemone");
|
||||
const [payloads, setPayloads] = useState<Record<DecisionEndpoint, string>>({
|
||||
"/v1/systemone": DECISIONS_EXAMPLE_PAYLOAD,
|
||||
"/typesafe/v1/systemone": EXAMPLE_PAYLOAD,
|
||||
});
|
||||
const rawPayload = payloads[endpoint];
|
||||
const examplePayload = endpoint === "/v1/systemone" ? DECISIONS_EXAMPLE_PAYLOAD : EXAMPLE_PAYLOAD;
|
||||
const effectiveApiKey = apiKeySource === "session" ? accessToken || "" : customApiKey.trim();
|
||||
const { decisionModels, isLoaded: decisionModelsLoaded } = useDecisionModels(
|
||||
apiKeySource,
|
||||
effectiveApiKey,
|
||||
getCustomProxyBaseUrl(),
|
||||
);
|
||||
const [chosenEndpoint, setChosenEndpoint] = useState<DecisionEndpoint | null>(null);
|
||||
const endpoint: DecisionEndpoint =
|
||||
chosenEndpoint ?? (decisionModels.length > 0 ? "/v1/systemone" : "/typesafe/v1/systemone");
|
||||
const [drafts, setDrafts] = useState<Partial<Record<DecisionEndpoint, string>>>({});
|
||||
const examplePayload =
|
||||
endpoint === "/v1/systemone"
|
||||
? JSON.stringify(decisionsExample(decisionModels[0] ?? PLACEHOLDER_DECISION_MODEL), null, 2)
|
||||
: EXAMPLE_PAYLOAD;
|
||||
const rawPayload = drafts[endpoint] ?? examplePayload;
|
||||
const activeController = useRef<AbortController | null>(null);
|
||||
const validation = useMemo(() => validateSystemOnePayload(rawPayload, endpoint), [rawPayload, endpoint]);
|
||||
const effectiveApiKey = apiKeySource === "session" ? accessToken || "" : customApiKey.trim();
|
||||
const hasSyntaxError = validation.issues.some((issue) => issue.path === "syntax");
|
||||
|
||||
const systemOne = useMutation({
|
||||
|
|
@ -76,7 +85,21 @@ export default function SystemOneUI({ accessToken, disabledPersonalKeyCreation =
|
|||
function handlePayloadChange(value: string) {
|
||||
if (value !== rawPayload) {
|
||||
clearRequestState();
|
||||
setPayloads((current) => ({ ...current, [endpoint]: value }));
|
||||
setChosenEndpoint(endpoint);
|
||||
setDrafts((current) => ({ ...current, [endpoint]: value }));
|
||||
}
|
||||
}
|
||||
|
||||
function handleResetExample() {
|
||||
clearRequestState();
|
||||
setChosenEndpoint(endpoint);
|
||||
setDrafts((current) => Object.fromEntries(Object.entries(current).filter(([key]) => key !== endpoint)));
|
||||
}
|
||||
|
||||
function handleModelPick(model: string | null) {
|
||||
const next = model === null ? undefined : withPayloadModel(rawPayload, model);
|
||||
if (next !== undefined) {
|
||||
handlePayloadChange(next);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -91,6 +114,7 @@ export default function SystemOneUI({ accessToken, disabledPersonalKeyCreation =
|
|||
return;
|
||||
}
|
||||
clearRequestState();
|
||||
setChosenEndpoint(endpoint);
|
||||
const controller = new AbortController();
|
||||
activeController.current = controller;
|
||||
const variables = { payload: validation.payload, apiKey: effectiveApiKey, signal: controller.signal, endpoint };
|
||||
|
|
@ -107,7 +131,7 @@ export default function SystemOneUI({ accessToken, disabledPersonalKeyCreation =
|
|||
onValueChange={(value) => {
|
||||
if (value === "/v1/systemone" || value === "/typesafe/v1/systemone") {
|
||||
clearRequestState();
|
||||
setEndpoint(value);
|
||||
setChosenEndpoint(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
|
@ -121,6 +145,28 @@ export default function SystemOneUI({ accessToken, disabledPersonalKeyCreation =
|
|||
<SelectItem value="/typesafe/v1/systemone">TypeSafe · /typesafe/v1/systemone</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{endpoint === "/v1/systemone" && (
|
||||
<>
|
||||
<span className="ml-2 text-sm font-medium text-muted-foreground">Model</span>
|
||||
<div className="w-80">
|
||||
<SearchSelect
|
||||
aria-label="Decision model"
|
||||
options={decisionModels.map((model) => ({ label: model, value: model }))}
|
||||
value={payloadModel(rawPayload) ?? null}
|
||||
onValueChange={handleModelPick}
|
||||
placeholder={decisionModels.length > 0 ? "Pick a decision model" : "No decision models yet"}
|
||||
emptyText="No decision models found"
|
||||
disabled={hasSyntaxError}
|
||||
allowClear={false}
|
||||
/>
|
||||
</div>
|
||||
{decisionModelsLoaded && decisionModels.length === 0 && (
|
||||
<a href={uiHref("models-and-endpoints")} className="text-sm text-primary underline">
|
||||
Add a decision model
|
||||
</a>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
|
|
@ -156,11 +202,7 @@ export default function SystemOneUI({ accessToken, disabledPersonalKeyCreation =
|
|||
)}
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handlePayloadChange(examplePayload)}
|
||||
disabled={rawPayload === examplePayload}
|
||||
>
|
||||
<Button variant="outline" onClick={handleResetExample} disabled={rawPayload === examplePayload}>
|
||||
<RotateCcw />
|
||||
Reset example
|
||||
</Button>
|
||||
|
|
@ -186,8 +228,12 @@ export default function SystemOneUI({ accessToken, disabledPersonalKeyCreation =
|
|||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{endpoint === "/v1/systemone"
|
||||
? "Sends choice, noul, and score questions through /v1/systemone. Replace the example model with a decision model configured on your proxy, or omit model to use the proxy's configured default."
|
||||
? "Sends choice, noul, and score questions through /v1/systemone to a decision model on your proxy. Pick one under Model, or omit model to use the proxy's configured default."
|
||||
: "Sends requests through /typesafe/v1/systemone and requires TYPESAFE_API_KEY on the proxy."}{" "}
|
||||
<a href={DECISIONS_DOCS_URL} target="_blank" rel="noopener noreferrer" className="underline">
|
||||
How to call /v1/decisions and /v1/systemone
|
||||
</a>
|
||||
{" · "}
|
||||
<a href={DECISION_MODELS_DISCUSSION_URL} target="_blank" rel="noopener noreferrer" className="underline">
|
||||
Give us feedback on what you want for decision models
|
||||
</a>
|
||||
|
|
|
|||
|
|
@ -37,7 +37,6 @@ export const SYSTEM_ONE_EXAMPLE = {
|
|||
},
|
||||
} satisfies SystemOneRequest;
|
||||
|
||||
export const DECISIONS_EXAMPLE: DecisionRequest = {
|
||||
...SYSTEM_ONE_EXAMPLE,
|
||||
model: "your-decision-model",
|
||||
};
|
||||
export const PLACEHOLDER_DECISION_MODEL = "your-decision-model";
|
||||
|
||||
export const decisionsExample = (model: string): DecisionRequest => ({ ...SYSTEM_ONE_EXAMPLE, model });
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { payloadModel, withPayloadModel } from "./payloadModel";
|
||||
|
||||
describe("payloadModel", () => {
|
||||
it("reads the model from a JSON object payload", () => {
|
||||
expect(payloadModel('{"model": "jev-latest", "state": "hi"}')).toBe("jev-latest");
|
||||
});
|
||||
|
||||
it("is undefined when the model is missing, empty, or not a string", () => {
|
||||
expect(payloadModel('{"state": "hi"}')).toBeUndefined();
|
||||
expect(payloadModel('{"model": ""}')).toBeUndefined();
|
||||
expect(payloadModel('{"model": 3}')).toBeUndefined();
|
||||
});
|
||||
|
||||
it("is undefined when the payload is not a JSON object", () => {
|
||||
expect(payloadModel("{not json")).toBeUndefined();
|
||||
expect(payloadModel('["jev-latest"]')).toBeUndefined();
|
||||
expect(payloadModel('"jev-latest"')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("withPayloadModel", () => {
|
||||
it("replaces the model and keeps every other field", () => {
|
||||
const next = withPayloadModel(
|
||||
'{"model": "old", "state": "hi", "questions": {"q": {"type": "noul"}}}',
|
||||
"jev-latest",
|
||||
);
|
||||
expect(next).toBeDefined();
|
||||
expect(JSON.parse(next ?? "")).toEqual({ model: "jev-latest", state: "hi", questions: { q: { type: "noul" } } });
|
||||
});
|
||||
|
||||
it("adds the model as the first field when the payload has none", () => {
|
||||
const next = withPayloadModel('{"state": "hi"}', "jev-latest");
|
||||
expect(Object.keys(JSON.parse(next ?? "{}"))).toEqual(["model", "state"]);
|
||||
});
|
||||
|
||||
it("leaves a payload that is not a JSON object alone", () => {
|
||||
expect(withPayloadModel("{not json", "jev-latest")).toBeUndefined();
|
||||
expect(withPayloadModel("[1, 2]", "jev-latest")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import { z } from "zod";
|
||||
import { parseJson } from "./validatePayload";
|
||||
|
||||
const jsonObject = z.record(z.string(), z.unknown());
|
||||
|
||||
const parseObject = (raw: string): Record<string, unknown> | undefined => {
|
||||
const json = parseJson(raw);
|
||||
if (!json.ok) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = jsonObject.safeParse(json.value);
|
||||
return parsed.success ? parsed.data : undefined;
|
||||
};
|
||||
|
||||
export const payloadModel = (raw: string): string | undefined => {
|
||||
const model = parseObject(raw)?.model;
|
||||
return typeof model === "string" && model !== "" ? model : undefined;
|
||||
};
|
||||
|
||||
export const withPayloadModel = (raw: string, model: string): string | undefined => {
|
||||
const payload = parseObject(raw);
|
||||
if (payload === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const rest = Object.fromEntries(Object.entries(payload).filter(([key]) => key !== "model"));
|
||||
return JSON.stringify({ model, ...rest }, null, 2);
|
||||
};
|
||||
|
|
@ -24,7 +24,7 @@ const invalid = (path: string, message: string): SystemOnePayloadValidation => (
|
|||
issues: [{ path, message, severity: "error" }],
|
||||
});
|
||||
|
||||
function parseJson(raw: string): { ok: true; value: unknown } | { ok: false; message: string } {
|
||||
export function parseJson(raw: string): { ok: true; value: unknown } | { ok: false; message: string } {
|
||||
try {
|
||||
return { ok: true, value: JSON.parse(raw) };
|
||||
} catch (error: unknown) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
import { useDebouncedValue } from "@tanstack/react-pacer/debouncer";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchDecisionModels } from "../../llm_calls/system_one";
|
||||
|
||||
export type ApiKeySource = "session" | "custom";
|
||||
|
||||
export interface DecisionModelsLookup {
|
||||
decisionModels: string[];
|
||||
isLoaded: boolean;
|
||||
}
|
||||
|
||||
const TYPED_KEY_DEBOUNCE_WAIT_MS = 400;
|
||||
const NO_DECISION_MODELS: string[] = [];
|
||||
|
||||
export function useDecisionModels(
|
||||
apiKeySource: ApiKeySource,
|
||||
apiKey: string,
|
||||
customBaseUrl: string | undefined,
|
||||
): DecisionModelsLookup {
|
||||
const typedApiKey = apiKeySource === "custom" ? apiKey : "";
|
||||
const [settledTypedApiKey] = useDebouncedValue(typedApiKey, { wait: TYPED_KEY_DEBOUNCE_WAIT_MS });
|
||||
const lookupApiKey = apiKeySource === "session" ? apiKey : settledTypedApiKey;
|
||||
const queryOptions = {
|
||||
queryKey: ["playground", "systemOne", "decisionModels", customBaseUrl ?? "", lookupApiKey],
|
||||
queryFn: ({ signal }: { signal: AbortSignal }) => fetchDecisionModels(lookupApiKey, customBaseUrl, signal),
|
||||
enabled: lookupApiKey !== "",
|
||||
retry: false,
|
||||
staleTime: 60 * 1000,
|
||||
};
|
||||
const query = useQuery(queryOptions);
|
||||
return { decisionModels: query.data ?? NO_DECISION_MODELS, isLoaded: query.isSuccess };
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { makeSystemOneRequest } from "./system_one";
|
||||
import { fetchDecisionModels, makeSystemOneRequest } from "./system_one";
|
||||
import type { SystemOneRequest, SystemOneResponse } from "../components/systemOneUI/lib/schemas";
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
|
|
@ -106,3 +106,46 @@ describe("makeSystemOneRequest", () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchDecisionModels", () => {
|
||||
const mockFetch = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>();
|
||||
const modelGroupInfo = (data: unknown) => ({ ok: true, text: async () => JSON.stringify({ data }) }) as Response;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("lists the key's decision models from the proxy that answers the request, sorted by name", async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
modelGroupInfo([
|
||||
{ model_group: "pplx-decider", mode: "evaluation" },
|
||||
{ model_group: "gpt-5.5", mode: "chat" },
|
||||
{ model_group: "jev-latest", mode: "evaluation" },
|
||||
{ model_group: "unknown-mode", mode: null },
|
||||
]),
|
||||
);
|
||||
|
||||
await expect(fetchDecisionModels("virtual-key", "https://custom.example.com/")).resolves.toEqual([
|
||||
"jev-latest",
|
||||
"pplx-decider",
|
||||
]);
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"https://custom.example.com/model_group/info",
|
||||
expect.objectContaining({
|
||||
method: "GET",
|
||||
headers: expect.objectContaining({ Authorization: "Bearer virtual-key" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a response that is not a model group list", async () => {
|
||||
mockFetch.mockResolvedValueOnce(modelGroupInfo([{ mode: "evaluation" }]));
|
||||
|
||||
await expect(fetchDecisionModels("virtual-key")).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { z } from "zod";
|
||||
import { getGlobalLitellmHeaderName, getProxyBaseUrl } from "@/components/networking";
|
||||
import { withRequiredHeaders } from "@/components/llm_calls/request_headers";
|
||||
import { createApiClient } from "@/lib/http/client";
|
||||
import { isDecisionMode } from "@/lib/decisionModels";
|
||||
import {
|
||||
systemOneResponseSchema,
|
||||
type DecisionEndpoint,
|
||||
|
|
@ -13,24 +15,54 @@ export interface SystemOneResult {
|
|||
latencyMs: number;
|
||||
}
|
||||
|
||||
export async function makeSystemOneRequest(
|
||||
payload: PlaygroundRequest,
|
||||
accessToken: string,
|
||||
customBaseUrl?: string,
|
||||
{ signal, endpoint = "/typesafe/v1/systemone" }: { signal?: AbortSignal; endpoint?: DecisionEndpoint } = {},
|
||||
): Promise<SystemOneResult> {
|
||||
const modelGroupInfoSchema = z.object({
|
||||
data: z.array(z.object({ model_group: z.string(), mode: z.string().nullish() })),
|
||||
});
|
||||
|
||||
function proxyClient(customBaseUrl?: string) {
|
||||
const proxyBaseUrl = customBaseUrl || getProxyBaseUrl();
|
||||
const normalizedBaseUrl = proxyBaseUrl.endsWith("/") ? proxyBaseUrl.slice(0, -1) : proxyBaseUrl;
|
||||
const startedAt = performance.now();
|
||||
const headers = withRequiredHeaders(
|
||||
return createApiClient({ getBaseUrl: () => normalizedBaseUrl });
|
||||
}
|
||||
|
||||
function authHeaders(accessToken: string): Record<string, string> {
|
||||
return withRequiredHeaders(
|
||||
{},
|
||||
{
|
||||
"Content-Type": "application/json",
|
||||
[getGlobalLitellmHeaderName()]: `Bearer ${accessToken}`,
|
||||
},
|
||||
);
|
||||
const client = createApiClient({ getBaseUrl: () => normalizedBaseUrl });
|
||||
const body = await client.post<unknown>(endpoint, { body: payload, headers, signal });
|
||||
}
|
||||
|
||||
export async function fetchDecisionModels(
|
||||
accessToken: string,
|
||||
customBaseUrl?: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string[]> {
|
||||
const body = await proxyClient(customBaseUrl).get<unknown>("/model_group/info", {
|
||||
headers: authHeaders(accessToken),
|
||||
signal,
|
||||
});
|
||||
return modelGroupInfoSchema
|
||||
.parse(body)
|
||||
.data.filter((group) => isDecisionMode(group.mode))
|
||||
.map((group) => group.model_group)
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
export async function makeSystemOneRequest(
|
||||
payload: PlaygroundRequest,
|
||||
accessToken: string,
|
||||
customBaseUrl?: string,
|
||||
{ signal, endpoint = "/typesafe/v1/systemone" }: { signal?: AbortSignal; endpoint?: DecisionEndpoint } = {},
|
||||
): Promise<SystemOneResult> {
|
||||
const startedAt = performance.now();
|
||||
const body = await proxyClient(customBaseUrl).post<unknown>(endpoint, {
|
||||
body: payload,
|
||||
headers: authHeaders(accessToken),
|
||||
signal,
|
||||
});
|
||||
const parsed = systemOneResponseSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
throw new Error("System One response has an invalid shape.");
|
||||
|
|
|
|||
|
|
@ -15,7 +15,15 @@ function PublicModelHubTableContent() {
|
|||
setAccessToken(key);
|
||||
}, [key]);
|
||||
|
||||
return <ModelHubTable accessToken={accessToken} publicPage={true} premiumUser={false} userRole={null} />;
|
||||
return (
|
||||
<ModelHubTable
|
||||
accessToken={accessToken}
|
||||
publicPage={true}
|
||||
premiumUser={false}
|
||||
userRole={null}
|
||||
canOpenPlayground={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PublicModelHubTable() {
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ vi.mock("@/components/networking", () => ({
|
|||
modelHubPublicModelsCall: vi.fn(),
|
||||
modelHubCall: vi.fn(),
|
||||
getConfigFieldSetting: vi.fn(),
|
||||
getGlobalLitellmHeaderName: vi.fn(() => "X-Litellm-Key"),
|
||||
getProxyBaseUrl: vi.fn(() => "http://localhost:4000"),
|
||||
getAgentsList: vi.fn(),
|
||||
fetchMCPServers: vi.fn(),
|
||||
|
|
@ -115,7 +116,15 @@ describe("ModelHubTable", () => {
|
|||
it(description, async () => {
|
||||
setupAuthRedirectTest(requireAuth, tokenValue, isTokenValid);
|
||||
|
||||
renderWithProviders(<ModelHubTable accessToken={null} publicPage={true} premiumUser={false} userRole={null} />);
|
||||
renderWithProviders(
|
||||
<ModelHubTable
|
||||
accessToken={null}
|
||||
publicPage={true}
|
||||
premiumUser={false}
|
||||
userRole={null}
|
||||
canOpenPlayground={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
if (shouldRedirect) {
|
||||
|
|
@ -148,7 +157,13 @@ describe("ModelHubTable", () => {
|
|||
});
|
||||
|
||||
renderWithProviders(
|
||||
<ModelHubTable accessToken="test-token" publicPage={false} premiumUser={false} userRole={null} />,
|
||||
<ModelHubTable
|
||||
accessToken="test-token"
|
||||
publicPage={false}
|
||||
premiumUser={false}
|
||||
userRole={null}
|
||||
canOpenPlayground
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
|
|
@ -165,7 +180,9 @@ describe("ModelHubTable", () => {
|
|||
isLoading: false,
|
||||
});
|
||||
|
||||
renderWithProviders(<ModelHubTable accessToken={null} publicPage={false} premiumUser={false} userRole={null} />);
|
||||
renderWithProviders(
|
||||
<ModelHubTable accessToken={null} publicPage={false} premiumUser={false} userRole={null} canOpenPlayground />,
|
||||
);
|
||||
|
||||
expect(await screen.findByText("No models yet")).toBeInTheDocument();
|
||||
expect(networking.modelHubCall).not.toHaveBeenCalled();
|
||||
|
|
@ -191,7 +208,15 @@ describe("ModelHubTable", () => {
|
|||
isLoading: false,
|
||||
});
|
||||
|
||||
renderWithProviders(<ModelHubTable accessToken={null} publicPage={true} premiumUser={false} userRole={null} />);
|
||||
renderWithProviders(
|
||||
<ModelHubTable
|
||||
accessToken={null}
|
||||
publicPage={true}
|
||||
premiumUser={false}
|
||||
userRole={null}
|
||||
canOpenPlayground={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getUiConfigMock).toHaveBeenCalled();
|
||||
|
|
@ -204,6 +229,66 @@ describe("ModelHubTable", () => {
|
|||
expect(getUiConfigCallOrder).toBeLessThan(modelHubPublicModelsCallOrder);
|
||||
});
|
||||
|
||||
describe("model details usage example", () => {
|
||||
const openDetails = async (
|
||||
model: { model_group: string; providers: string[]; mode: string },
|
||||
canOpenPlayground = true,
|
||||
) => {
|
||||
vi.mocked(networking.modelHubCall).mockResolvedValue({ data: [model] });
|
||||
vi.mocked(networking.getConfigFieldSetting).mockResolvedValue({ field_value: false });
|
||||
vi.mocked(networking.getAgentsList).mockResolvedValue({ agents: [] });
|
||||
vi.mocked(networking.fetchMCPServers).mockResolvedValue([]);
|
||||
vi.mocked(networking.getUiSettings).mockResolvedValue({ values: {} });
|
||||
mockUseUISettings.mockReturnValue({ data: { values: {} }, isLoading: false });
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<ModelHubTable
|
||||
accessToken="test-token"
|
||||
publicPage={false}
|
||||
premiumUser={false}
|
||||
userRole="Admin"
|
||||
canOpenPlayground={canOpenPlayground}
|
||||
/>,
|
||||
);
|
||||
await user.click(await screen.findByRole("button", { name: model.model_group }));
|
||||
return screen.findByRole("dialog");
|
||||
};
|
||||
|
||||
it("shows a decision model how to call it on /v1/systemone, with the docs and the playground", async () => {
|
||||
const dialog = await openDetails({ model_group: "jev-latest", providers: ["typesafe"], mode: "evaluation" });
|
||||
|
||||
expect(dialog).toHaveTextContent('"http://localhost:4000/v1/systemone"');
|
||||
expect(dialog).toHaveTextContent('headers={"X-Litellm-Key": "Bearer your_api_key"}');
|
||||
expect(dialog).not.toHaveTextContent("chat.completions");
|
||||
expect(screen.getByRole("link", { name: "How to call decision models" })).toHaveAttribute(
|
||||
"href",
|
||||
"https://docs.litellm.ai/docs/decisions",
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "Try it in the Playground" })).toHaveAttribute(
|
||||
"href",
|
||||
"/ui/playground?tab=system-one",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not link a view-only session to the Playground it cannot open", async () => {
|
||||
const dialog = await openDetails(
|
||||
{ model_group: "jev-latest", providers: ["typesafe"], mode: "evaluation" },
|
||||
false,
|
||||
);
|
||||
|
||||
expect(dialog).toHaveTextContent('"http://localhost:4000/v1/systemone"');
|
||||
expect(screen.getByRole("link", { name: "How to call decision models" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("link", { name: "Try it in the Playground" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the chat completions example for a chat model", async () => {
|
||||
const dialog = await openDetails({ model_group: "claude-opus-4-8", providers: ["anthropic"], mode: "chat" });
|
||||
|
||||
expect(dialog).toHaveTextContent("client.chat.completions.create(");
|
||||
expect(screen.queryByRole("link", { name: "How to call decision models" })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("hub tabs", () => {
|
||||
const renderHub = async (agents: object[] = [], mcpServers: Promise<MCPServerData[]> = Promise.resolve([])) => {
|
||||
vi.mocked(networking.modelHubCall).mockResolvedValue({
|
||||
|
|
@ -217,7 +302,13 @@ describe("ModelHubTable", () => {
|
|||
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<ModelHubTable accessToken="test-token" publicPage={false} premiumUser={false} userRole="Admin" />,
|
||||
<ModelHubTable
|
||||
accessToken="test-token"
|
||||
publicPage={false}
|
||||
premiumUser={false}
|
||||
userRole="Admin"
|
||||
canOpenPlayground
|
||||
/>,
|
||||
);
|
||||
return { user, search: await screen.findByPlaceholderText("Search model names...") };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import MakeMCPPublicForm from "@/components/AIHub/forms/MakeMCPPublicForm";
|
|||
import MakeModelPublicForm from "@/components/AIHub/forms/MakeModelPublicForm";
|
||||
import { getMCPHubTableColumns, MCPServerData } from "@/components/AIHub/MCPHubTableColumns";
|
||||
import { getModelHubTableColumns, ModelHubData } from "@/components/AIHub/ModelHubTableColumns";
|
||||
import { modelUsageExample } from "@/components/AIHub/modelUsageExample";
|
||||
import UsefulLinksManagement from "@/components/AIHub/UsefulLinksManagement";
|
||||
import { getClaudeCodePluginsList } from "@/components/networking";
|
||||
import { Plugin } from "@/components/claude_code_plugins/types";
|
||||
|
|
@ -15,6 +16,7 @@ import {
|
|||
fetchMCPServers,
|
||||
getAgentsList,
|
||||
getConfigFieldSetting,
|
||||
getGlobalLitellmHeaderName,
|
||||
getProxyBaseUrl,
|
||||
getUiConfig,
|
||||
modelHubCall,
|
||||
|
|
@ -41,12 +43,15 @@ import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings"
|
|||
import { checkTokenValidity } from "@/utils/jwtUtils";
|
||||
import { getCookie } from "@/utils/cookieUtils";
|
||||
import { getLoginUrl } from "@/utils/returnUrlUtils";
|
||||
import { uiHref } from "@/utils/uiHref";
|
||||
import { DECISIONS_DOCS_URL, SYSTEM_ONE_PLAYGROUND_ROUTE, isDecisionMode } from "@/lib/decisionModels";
|
||||
|
||||
interface ModelHubTableProps {
|
||||
accessToken: string | null;
|
||||
publicPage: boolean;
|
||||
premiumUser: boolean;
|
||||
userRole: string | null;
|
||||
canOpenPlayground: boolean;
|
||||
}
|
||||
|
||||
function isMCPHubVisibilityDisabled(isLoading: boolean, servers: readonly MCPServerData[] | null): boolean {
|
||||
|
|
@ -65,7 +70,13 @@ function HubEmptyState({ title, body }: { title: string; body: string }) {
|
|||
);
|
||||
}
|
||||
|
||||
const ModelHubTable: React.FC<ModelHubTableProps> = ({ accessToken, publicPage, premiumUser, userRole }) => {
|
||||
const ModelHubTable: React.FC<ModelHubTableProps> = ({
|
||||
accessToken,
|
||||
publicPage,
|
||||
premiumUser,
|
||||
userRole,
|
||||
canOpenPlayground,
|
||||
}) => {
|
||||
const syntaxTheme = useSyntaxTheme(prism);
|
||||
// Admin Viewer follows the read-parity rule: see the AI Hub catalog, but
|
||||
// cannot toggle public visibility (write).
|
||||
|
|
@ -763,28 +774,32 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({ accessToken, publicPage,
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Usage Example */}
|
||||
<div>
|
||||
<p className="text-lg font-semibold mb-4">Usage Example</p>
|
||||
{isDecisionMode(selectedModel.mode) && (
|
||||
<p className="mb-2 text-sm text-muted-foreground">
|
||||
Decision models answer on <code>/v1/decisions</code> and <code>/v1/systemone</code>, not{" "}
|
||||
<code>/chat/completions</code>.{" "}
|
||||
<a href={DECISIONS_DOCS_URL} target="_blank" rel="noopener noreferrer" className="underline">
|
||||
How to call decision models
|
||||
</a>
|
||||
{canOpenPlayground && (
|
||||
<>
|
||||
{" · "}
|
||||
<a href={uiHref(SYSTEM_ONE_PLAYGROUND_ROUTE)} className="underline">
|
||||
Try it in the Playground
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<SyntaxHighlighter language="python" className="text-sm" style={syntaxTheme}>
|
||||
{`import openai
|
||||
|
||||
client = openai.OpenAI(
|
||||
api_key="your_api_key",
|
||||
base_url="${getProxyBaseUrl()}" # Your LiteLLM Proxy URL
|
||||
)
|
||||
|
||||
response = client.chat.completions.create(
|
||||
model="${selectedModel.model_group}",
|
||||
messages=[
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Hello, how are you?"
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
print(response.choices[0].message.content)`}
|
||||
{modelUsageExample(
|
||||
selectedModel.mode,
|
||||
getProxyBaseUrl(),
|
||||
selectedModel.model_group,
|
||||
getGlobalLitellmHeaderName(),
|
||||
)}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { modelUsageExample } from "./modelUsageExample";
|
||||
|
||||
const BASE_URL = "https://proxy.example.com";
|
||||
|
||||
describe("modelUsageExample", () => {
|
||||
it("posts a decision model to /v1/systemone on the proxy", () => {
|
||||
const example = modelUsageExample("evaluation", BASE_URL, "jev-latest", "Authorization");
|
||||
expect(example).toContain('"https://proxy.example.com/v1/systemone"');
|
||||
expect(example).toContain('"model": "jev-latest"');
|
||||
expect(example).toContain('"type": "noul"');
|
||||
expect(example).not.toContain("chat.completions");
|
||||
});
|
||||
|
||||
it("sends the key in the header the proxy reads keys from", () => {
|
||||
expect(modelUsageExample("evaluation", BASE_URL, "jev-latest", "Authorization")).toContain(
|
||||
'headers={"Authorization": "Bearer your_api_key"}',
|
||||
);
|
||||
expect(modelUsageExample("evaluation", BASE_URL, "jev-latest", "X-Litellm-Key")).toContain(
|
||||
'headers={"X-Litellm-Key": "Bearer your_api_key"}',
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the chat completions example for every other mode", () => {
|
||||
for (const mode of ["chat", null, undefined]) {
|
||||
const example = modelUsageExample(mode, BASE_URL, "gpt-5.5", "Authorization");
|
||||
expect(example).toContain("client.chat.completions.create(");
|
||||
expect(example).toContain('base_url="https://proxy.example.com"');
|
||||
expect(example).toContain('model="gpt-5.5"');
|
||||
expect(example).not.toContain("/v1/systemone");
|
||||
}
|
||||
});
|
||||
|
||||
it("escapes quotes in a model name so the snippet stays valid Python", () => {
|
||||
expect(modelUsageExample("evaluation", BASE_URL, 'team"model', "Authorization")).toContain(
|
||||
'"model": "team\\"model"',
|
||||
);
|
||||
expect(modelUsageExample("chat", BASE_URL, 'team"model', "Authorization")).toContain('model="team\\"model"');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
import { isDecisionMode } from "@/lib/decisionModels";
|
||||
|
||||
const chatCompletionsExample = (baseUrl: string, model: string): string => `import openai
|
||||
|
||||
client = openai.OpenAI(
|
||||
api_key="your_api_key",
|
||||
base_url=${JSON.stringify(baseUrl)} # Your LiteLLM Proxy URL
|
||||
)
|
||||
|
||||
response = client.chat.completions.create(
|
||||
model=${JSON.stringify(model)},
|
||||
messages=[
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Hello, how are you?"
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
print(response.choices[0].message.content)`;
|
||||
|
||||
const systemOneExample = (baseUrl: string, model: string, keyHeaderName: string): string => `import requests
|
||||
|
||||
response = requests.post(
|
||||
${JSON.stringify(`${baseUrl}/v1/systemone`)}, # Your LiteLLM Proxy URL
|
||||
headers={${JSON.stringify(keyHeaderName)}: "Bearer your_api_key"},
|
||||
json={
|
||||
"model": ${JSON.stringify(model)},
|
||||
"state": "I was charged twice for my subscription this month.",
|
||||
"questions": {
|
||||
"wants_refund": {
|
||||
"type": "noul",
|
||||
"instructions": "Is the customer asking for money back?"
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
print(response.json()["answers"])`;
|
||||
|
||||
export const modelUsageExample = (
|
||||
mode: string | null | undefined,
|
||||
baseUrl: string,
|
||||
model: string,
|
||||
keyHeaderName: string,
|
||||
): string =>
|
||||
isDecisionMode(mode) ? systemOneExample(baseUrl, model, keyHeaderName) : chatCompletionsExample(baseUrl, model);
|
||||
|
|
@ -15,6 +15,7 @@ import { Providers } from "../provider_info_helpers";
|
|||
import { projectMountedValues, useMountRegistry, type MountedFormValues } from "../common_components/MountedFormField";
|
||||
import { useForm } from "react-hook-form";
|
||||
import AddModelForm from "./AddModelForm";
|
||||
import { EMPTY_DECISION_CATALOG } from "@/lib/decisionModels";
|
||||
import { handleAddModelSubmit } from "./handle_add_model_submit";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
|
|
@ -245,6 +246,7 @@ const createTestProps = (userRole = "proxy_admin", userId = "user-1", isTeamAdmi
|
|||
showAdvancedSettings: false,
|
||||
teams,
|
||||
credentials,
|
||||
decisionCatalog: EMPTY_DECISION_CATALOG,
|
||||
userRole,
|
||||
userId,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ import ConnectionErrorDisplay from "./model_connection_test";
|
|||
import ProviderSpecificFields from "./provider_specific_fields";
|
||||
import { authTypesFor } from "./provider_auth_types";
|
||||
import { TEST_MODES } from "./add_model_modes";
|
||||
import DecisionModelNote from "./DecisionModelNote";
|
||||
import { decisionModelsSublabel, type DecisionCatalog } from "@/lib/decisionModels";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { credentialsKeys } from "@/app/(dashboard)/hooks/credentials/useCredentials";
|
||||
import { extractProxyErrorMessage } from "@/lib/http/client";
|
||||
|
|
@ -59,6 +61,7 @@ interface AddModelFormProps {
|
|||
setShowAdvancedSettings: (show: boolean) => void;
|
||||
teams: Team[] | null;
|
||||
credentials: CredentialItem[];
|
||||
decisionCatalog: DecisionCatalog;
|
||||
}
|
||||
|
||||
const connectionTestModelName = (values: MountedFormValues): string | undefined => {
|
||||
|
|
@ -82,6 +85,7 @@ const AddModelForm: React.FC<AddModelFormProps> = ({
|
|||
setShowAdvancedSettings,
|
||||
teams,
|
||||
credentials,
|
||||
decisionCatalog,
|
||||
}) => {
|
||||
const [testMode, setTestMode] = useState<string>("chat");
|
||||
const [isResultModalVisible, setIsResultModalVisible] = useState<boolean>(false);
|
||||
|
|
@ -99,6 +103,7 @@ const AddModelForm: React.FC<AddModelFormProps> = ({
|
|||
const guardrailsList = guardrailsData?.guardrails.map((g) => g.guardrail_name);
|
||||
const { data: tagsList } = useTags();
|
||||
const selectedCredentialName = useWatch({ control: form.control, name: "litellm_credential_name" });
|
||||
const pickedProvider = useWatch({ control: form.control, name: "custom_llm_provider" });
|
||||
const queryClient = useQueryClient();
|
||||
const [isCredentialModalOpen, setIsCredentialModalOpen] = useState(false);
|
||||
const [credentialModalAuthTypeId, setCredentialModalAuthTypeId] = useState<string | undefined>();
|
||||
|
|
@ -168,11 +173,16 @@ const AddModelForm: React.FC<AddModelFormProps> = ({
|
|||
sortedProviderMetadata.map((providerInfo) => ({
|
||||
label: providerInfo.provider_display_name,
|
||||
value: providerInfo.provider,
|
||||
sublabel: decisionModelsSublabel(decisionCatalog, providerInfo.litellm_provider),
|
||||
icon: <ProviderLogo provider={providerInfo.provider} className="w-5 h-5" />,
|
||||
})),
|
||||
[sortedProviderMetadata],
|
||||
[sortedProviderMetadata, decisionCatalog],
|
||||
);
|
||||
|
||||
const pickedLitellmProvider = sortedProviderMetadata.find(
|
||||
(providerInfo) => providerInfo.provider === pickedProvider,
|
||||
)?.litellm_provider;
|
||||
|
||||
const credentialSelectOptions: SearchSelectOption[] = useMemo(() => credentialOptions(credentials), [credentials]);
|
||||
|
||||
const applyProviderSelection = (provider: string | null) => {
|
||||
|
|
@ -274,6 +284,7 @@ const AddModelForm: React.FC<AddModelFormProps> = ({
|
|||
providerModels={providerModels}
|
||||
getPlaceholder={getPlaceholder}
|
||||
/>
|
||||
<DecisionModelNote catalog={decisionCatalog} litellmProvider={pickedLitellmProvider} />
|
||||
|
||||
{/* Conditionally Render "Public Model Name" */}
|
||||
<ConditionalPublicModelName />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
import { Info } from "lucide-react";
|
||||
import { useFormContext, useWatch } from "react-hook-form";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert";
|
||||
import {
|
||||
DECISIONS_DOCS_URL,
|
||||
SYSTEM_ONE_PLAYGROUND_ROUTE,
|
||||
isDecisionSelection,
|
||||
type DecisionCatalog,
|
||||
} from "@/lib/decisionModels";
|
||||
import { uiHref } from "@/utils/uiHref";
|
||||
import type { MountedFormValues } from "../common_components/MountedFormField";
|
||||
|
||||
interface DecisionModelNoteProps {
|
||||
catalog: DecisionCatalog;
|
||||
litellmProvider: string | undefined;
|
||||
}
|
||||
|
||||
const selectedModelNames = (value: unknown): string[] => {
|
||||
if (Array.isArray(value)) {
|
||||
return value.filter((model): model is string => typeof model === "string");
|
||||
}
|
||||
return typeof value === "string" && value !== "" ? [value] : [];
|
||||
};
|
||||
|
||||
export default function DecisionModelNote({ catalog, litellmProvider }: DecisionModelNoteProps) {
|
||||
const { control } = useFormContext<MountedFormValues>();
|
||||
const model = useWatch({ control, name: "model" });
|
||||
|
||||
if (!isDecisionSelection(catalog, litellmProvider, selectedModelNames(model))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert variant="info" role="note" aria-label="Decision model notice" className="mb-4">
|
||||
<Info />
|
||||
<AlertTitle>Decision model</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p>
|
||||
For decision requests, call <code>/v1/decisions</code> (OpenAI format) or <code>/v1/systemone</code> (System
|
||||
One format).{" "}
|
||||
<a href={DECISIONS_DOCS_URL} target="_blank" rel="noopener noreferrer" className="underline">
|
||||
How to call decision models
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
After you add it,{" "}
|
||||
<a href={uiHref(SYSTEM_ONE_PLAYGROUND_ROUTE)} className="underline">
|
||||
test it in the System One playground
|
||||
</a>
|
||||
</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import DecisionModelsBanner from "./DecisionModelsBanner";
|
||||
|
||||
const STORAGE_KEY = "hideDecisionModelsBanner";
|
||||
|
||||
describe("DecisionModelsBanner", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
localStorage.removeItem("hideCostOptimizationFeedbackBanner");
|
||||
});
|
||||
|
||||
it("links directly to the System One playground", () => {
|
||||
render(<DecisionModelsBanner />);
|
||||
const link = screen.getByRole("link", { name: "Try decision models" });
|
||||
expect(link).toHaveAttribute("href", "/ui/playground?tab=system-one");
|
||||
});
|
||||
|
||||
it("links to the docs on how to call decision models", () => {
|
||||
render(<DecisionModelsBanner />);
|
||||
expect(screen.getByRole("link", { name: "How to call them" })).toHaveAttribute(
|
||||
"href",
|
||||
"https://docs.litellm.ai/docs/decisions",
|
||||
);
|
||||
});
|
||||
|
||||
it("offers Add a decision model only when the page can add models, and calls back on click", () => {
|
||||
const onAddModel = vi.fn();
|
||||
const { rerender } = render(<DecisionModelsBanner />);
|
||||
expect(screen.queryByRole("button", { name: "Add a decision model" })).not.toBeInTheDocument();
|
||||
|
||||
rerender(<DecisionModelsBanner onAddModel={onAddModel} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add a decision model" }));
|
||||
|
||||
expect(onAddModel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("points only users who can add models at Add Model", () => {
|
||||
const { rerender } = render(<DecisionModelsBanner />);
|
||||
expect(screen.getByRole("paragraph")).not.toHaveTextContent("Add Model");
|
||||
expect(screen.getByRole("paragraph")).toHaveTextContent("Call them at /v1/decisions or /v1/systemone");
|
||||
|
||||
rerender(<DecisionModelsBanner onAddModel={vi.fn()} />);
|
||||
|
||||
expect(screen.getByRole("paragraph")).toHaveTextContent(
|
||||
"Search decision in Add Model to find them. Call them at /v1/decisions",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the announcement even if the old feedback banner was dismissed", () => {
|
||||
localStorage.setItem("hideCostOptimizationFeedbackBanner", "true");
|
||||
render(<DecisionModelsBanner />);
|
||||
expect(screen.getByText("Decision models are now supported")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides itself and persists the dismissal when the dismiss button is clicked", () => {
|
||||
render(<DecisionModelsBanner />);
|
||||
expect(screen.getByText("Decision models are now supported")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByLabelText("Dismiss banner"));
|
||||
|
||||
expect(screen.queryByText("Decision models are now supported")).not.toBeInTheDocument();
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBe("true");
|
||||
});
|
||||
|
||||
it("stays dismissed on remount once persisted", () => {
|
||||
localStorage.setItem(STORAGE_KEY, "true");
|
||||
render(<DecisionModelsBanner />);
|
||||
expect(screen.queryByText("Decision models are now supported")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
import React, { useState } from "react";
|
||||
import { ArrowRight, Sparkles, X } from "lucide-react";
|
||||
|
||||
import { DECISIONS_DOCS_URL, SYSTEM_ONE_PLAYGROUND_ROUTE } from "@/lib/decisionModels";
|
||||
import { uiHref } from "@/utils/uiHref";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const STORAGE_KEY = "hideDecisionModelsBanner";
|
||||
|
||||
interface DecisionModelsBannerProps {
|
||||
onAddModel?: () => void;
|
||||
}
|
||||
|
||||
const DecisionModelsBanner: React.FC<DecisionModelsBannerProps> = ({ onAddModel }) => {
|
||||
const [dismissed, setDismissed] = useState(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
return localStorage.getItem(STORAGE_KEY) === "true";
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (dismissed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-4 flex items-center gap-4 rounded-lg border bg-muted/40 px-4 py-3">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-full border bg-background">
|
||||
<Sparkles className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h4 className="m-0 text-sm font-semibold text-foreground">Decision models are now supported</h4>
|
||||
<p className="m-0 mt-0.5 text-xs text-muted-foreground">
|
||||
{onAddModel && (
|
||||
<>
|
||||
Search <code>decision</code> in Add Model to find them.{" "}
|
||||
</>
|
||||
)}
|
||||
Call them at <code>/v1/decisions</code> or <code>/v1/systemone</code>, or try them in the System One
|
||||
playground.{" "}
|
||||
<a href={DECISIONS_DOCS_URL} target="_blank" rel="noopener noreferrer" className="underline">
|
||||
How to call them
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
{onAddModel && (
|
||||
<Button type="button" variant="outline" className="shrink-0" onClick={onAddModel}>
|
||||
Add a decision model
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
className="shrink-0"
|
||||
nativeButton={false}
|
||||
role="link"
|
||||
render={<a href={uiHref(SYSTEM_ONE_PLAYGROUND_ROUTE)} />}
|
||||
>
|
||||
Try decision models
|
||||
<ArrowRight />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => {
|
||||
setDismissed(true);
|
||||
localStorage.setItem(STORAGE_KEY, "true");
|
||||
}}
|
||||
className="shrink-0"
|
||||
aria-label="Dismiss banner"
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DecisionModelsBanner;
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import CostOptimizationFeedbackBanner from "./cost_optimization_feedback_banner";
|
||||
|
||||
const STORAGE_KEY = "hideCostOptimizationFeedbackBanner";
|
||||
|
||||
describe("CostOptimizationFeedbackBanner", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
});
|
||||
|
||||
it("renders with a link to the feedback discussion", () => {
|
||||
render(<CostOptimizationFeedbackBanner />);
|
||||
const link = screen.getByText("Share Feedback").closest("a");
|
||||
expect(link).toHaveAttribute("href", "https://github.com/BerriAI/litellm/discussions/32172");
|
||||
});
|
||||
|
||||
it("hides itself and persists the dismissal when the dismiss button is clicked", () => {
|
||||
render(<CostOptimizationFeedbackBanner />);
|
||||
expect(screen.getByText("Help shape cost optimization")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByLabelText("Dismiss banner"));
|
||||
|
||||
expect(screen.queryByText("Help shape cost optimization")).not.toBeInTheDocument();
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBe("true");
|
||||
});
|
||||
|
||||
it("stays dismissed on remount once persisted", () => {
|
||||
localStorage.setItem(STORAGE_KEY, "true");
|
||||
render(<CostOptimizationFeedbackBanner />);
|
||||
expect(screen.queryByText("Help shape cost optimization")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
import React, { useState } from "react";
|
||||
import { ExternalLink, MessageSquare, X } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const STORAGE_KEY = "hideCostOptimizationFeedbackBanner";
|
||||
const DISCUSSION_URL = "https://github.com/BerriAI/litellm/discussions/32172";
|
||||
|
||||
const CostOptimizationFeedbackBanner: React.FC = () => {
|
||||
const [dismissed, setDismissed] = useState(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
return localStorage.getItem(STORAGE_KEY) === "true";
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (dismissed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-4 flex items-center gap-4 rounded-lg border bg-muted/40 px-4 py-3">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-full border bg-background">
|
||||
<MessageSquare className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h4 className="m-0 text-sm font-semibold text-foreground">Help shape cost optimization</h4>
|
||||
<p className="m-0 mt-0.5 text-xs text-muted-foreground">
|
||||
We're collecting suggestions for cost optimization improvements across routing, budgets, and more. Let us
|
||||
know what you'd like to see.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
className="shrink-0"
|
||||
nativeButton={false}
|
||||
render={<a href={DISCUSSION_URL} target="_blank" rel="noopener noreferrer" />}
|
||||
>
|
||||
Share Feedback
|
||||
<ExternalLink />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => {
|
||||
setDismissed(true);
|
||||
localStorage.setItem(STORAGE_KEY, "true");
|
||||
}}
|
||||
className="shrink-0"
|
||||
aria-label="Dismiss banner"
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CostOptimizationFeedbackBanner;
|
||||
108
ui/litellm-dashboard/src/lib/decisionModels.test.ts
Normal file
108
ui/litellm-dashboard/src/lib/decisionModels.test.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
EMPTY_DECISION_CATALOG,
|
||||
buildDecisionCatalog,
|
||||
decisionModelsSublabel,
|
||||
isDecisionMode,
|
||||
isDecisionSelection,
|
||||
} from "./decisionModels";
|
||||
|
||||
const COST_MAP = {
|
||||
"typesafe/jev-latest": { litellm_provider: "typesafe", mode: "evaluation" },
|
||||
"typesafe/jev-preview": { litellm_provider: "typesafe", mode: "evaluation" },
|
||||
"gpt-6-luna": {
|
||||
litellm_provider: "openai",
|
||||
mode: "chat",
|
||||
supported_endpoints: ["/v1/chat/completions", "/v1/decisions"],
|
||||
},
|
||||
"gpt-5.5": { litellm_provider: "openai", mode: "chat", supported_endpoints: ["/v1/chat/completions"] },
|
||||
"perplexity/pplx-decider-v1-27b": { litellm_provider: "perplexity", mode: "evaluation" },
|
||||
"perplexity/sonar-pro": { litellm_provider: "perplexity", mode: "chat" },
|
||||
"systemone-only": { litellm_provider: "somevendor", supported_endpoints: ["/v1/systemone"] },
|
||||
sample_spec: { max_tokens: "set to max_output_tokens if provider specifies it" },
|
||||
};
|
||||
|
||||
describe("isDecisionMode", () => {
|
||||
it("is true only for the evaluation mode", () => {
|
||||
expect(isDecisionMode("evaluation")).toBe(true);
|
||||
expect(isDecisionMode("chat")).toBe(false);
|
||||
expect(isDecisionMode(null)).toBe(false);
|
||||
expect(isDecisionMode(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDecisionCatalog", () => {
|
||||
const catalog = buildDecisionCatalog(COST_MAP);
|
||||
|
||||
it("collects evaluation models and models that list a decision endpoint", () => {
|
||||
expect([...catalog.models].sort()).toEqual(
|
||||
[
|
||||
"gpt-6-luna",
|
||||
"perplexity/pplx-decider-v1-27b",
|
||||
"systemone-only",
|
||||
"typesafe/jev-latest",
|
||||
"typesafe/jev-preview",
|
||||
].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it("strips the provider prefix from the names shown per provider", () => {
|
||||
expect(catalog.providers.get("typesafe")?.names).toEqual(["jev-latest", "jev-preview"]);
|
||||
expect(catalog.providers.get("perplexity")?.names).toEqual(["pplx-decider-v1-27b"]);
|
||||
expect(catalog.providers.get("openai")?.names).toEqual(["gpt-6-luna"]);
|
||||
});
|
||||
|
||||
it("marks a provider decision-only when every one of its models is a decision model", () => {
|
||||
expect(catalog.providers.get("typesafe")?.decisionOnly).toBe(true);
|
||||
expect(catalog.providers.get("somevendor")?.decisionOnly).toBe(true);
|
||||
expect(catalog.providers.get("perplexity")?.decisionOnly).toBe(false);
|
||||
expect(catalog.providers.get("openai")?.decisionOnly).toBe(false);
|
||||
});
|
||||
|
||||
it("leaves providers without decision models out", () => {
|
||||
expect(catalog.providers.has("anthropic")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns an empty catalog for a missing or malformed cost map", () => {
|
||||
expect(buildDecisionCatalog(undefined)).toEqual(EMPTY_DECISION_CATALOG);
|
||||
expect(buildDecisionCatalog("not a map")).toEqual(EMPTY_DECISION_CATALOG);
|
||||
expect(buildDecisionCatalog({ broken: { mode: "evaluation" } })).toEqual(EMPTY_DECISION_CATALOG);
|
||||
});
|
||||
});
|
||||
|
||||
describe("decisionModelsSublabel", () => {
|
||||
const catalog = buildDecisionCatalog(COST_MAP);
|
||||
|
||||
it("lists the provider's decision models", () => {
|
||||
expect(decisionModelsSublabel(catalog, "typesafe")).toBe("Decision models: jev-latest, jev-preview");
|
||||
});
|
||||
|
||||
it("is undefined for a provider with no decision models", () => {
|
||||
expect(decisionModelsSublabel(catalog, "anthropic")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isDecisionSelection", () => {
|
||||
const catalog = buildDecisionCatalog(COST_MAP);
|
||||
|
||||
it("is true for a decision-only provider before any model is picked", () => {
|
||||
expect(isDecisionSelection(catalog, "typesafe", [])).toBe(true);
|
||||
});
|
||||
|
||||
it("waits for a decision model on a provider that also serves chat models", () => {
|
||||
expect(isDecisionSelection(catalog, "perplexity", [])).toBe(false);
|
||||
expect(isDecisionSelection(catalog, "perplexity", ["perplexity/sonar-pro"])).toBe(false);
|
||||
expect(isDecisionSelection(catalog, "perplexity", ["perplexity/sonar-pro", "perplexity/pplx-decider-v1-27b"])).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("is true for a chat-mode model that lists a decision endpoint", () => {
|
||||
expect(isDecisionSelection(catalog, "openai", ["gpt-6-luna"])).toBe(true);
|
||||
expect(isDecisionSelection(catalog, "openai", ["gpt-5.5"])).toBe(false);
|
||||
});
|
||||
|
||||
it("is false with no provider and no models", () => {
|
||||
expect(isDecisionSelection(catalog, undefined, [])).toBe(false);
|
||||
});
|
||||
});
|
||||
74
ui/litellm-dashboard/src/lib/decisionModels.ts
Normal file
74
ui/litellm-dashboard/src/lib/decisionModels.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const DECISIONS_DOCS_URL = "https://docs.litellm.ai/docs/decisions";
|
||||
export const SYSTEM_ONE_PLAYGROUND_ROUTE = "playground?tab=system-one";
|
||||
|
||||
const DECISION_ENDPOINTS: ReadonlySet<string> = new Set(["/v1/decisions", "/v1/systemone"]);
|
||||
|
||||
const costMapEntrySchema = z.object({
|
||||
litellm_provider: z.string(),
|
||||
mode: z.string().nullish(),
|
||||
supported_endpoints: z.array(z.string()).nullish(),
|
||||
});
|
||||
|
||||
type CostMapEntry = z.infer<typeof costMapEntrySchema>;
|
||||
|
||||
interface ProviderDecisionModels {
|
||||
readonly names: readonly string[];
|
||||
readonly decisionOnly: boolean;
|
||||
}
|
||||
|
||||
export interface DecisionCatalog {
|
||||
readonly models: ReadonlySet<string>;
|
||||
readonly providers: ReadonlyMap<string, ProviderDecisionModels>;
|
||||
}
|
||||
|
||||
export const EMPTY_DECISION_CATALOG: DecisionCatalog = { models: new Set(), providers: new Map() };
|
||||
|
||||
export const isDecisionMode = (mode: string | null | undefined): boolean => mode === "evaluation";
|
||||
|
||||
const isDecisionEntry = (entry: CostMapEntry): boolean =>
|
||||
isDecisionMode(entry.mode) || (entry.supported_endpoints ?? []).some((endpoint) => DECISION_ENDPOINTS.has(endpoint));
|
||||
|
||||
const withoutProviderPrefix = (key: string, provider: string): string =>
|
||||
key.startsWith(`${provider}/`) ? key.slice(provider.length + 1) : key;
|
||||
|
||||
const parsedEntries = (costMap: unknown): ReadonlyArray<readonly [string, CostMapEntry]> => {
|
||||
const record = z.record(z.string(), z.unknown()).safeParse(costMap);
|
||||
if (!record.success) {
|
||||
return [];
|
||||
}
|
||||
return Object.entries(record.data).flatMap(([key, value]) => {
|
||||
const entry = costMapEntrySchema.safeParse(value);
|
||||
return entry.success ? [[key, entry.data] as const] : [];
|
||||
});
|
||||
};
|
||||
|
||||
export function buildDecisionCatalog(costMap: unknown): DecisionCatalog {
|
||||
const entries = parsedEntries(costMap);
|
||||
const decisionEntries = entries.filter(([, entry]) => isDecisionEntry(entry));
|
||||
const decisionProviders = new Set(decisionEntries.map(([, entry]) => entry.litellm_provider));
|
||||
const providers = new Map(
|
||||
[...decisionProviders].map((provider) => {
|
||||
const providerEntries = entries.filter(([, entry]) => entry.litellm_provider === provider);
|
||||
const names = decisionEntries
|
||||
.filter(([, entry]) => entry.litellm_provider === provider)
|
||||
.map(([key]) => withoutProviderPrefix(key, provider));
|
||||
return [provider, { names, decisionOnly: providerEntries.every(([, entry]) => isDecisionEntry(entry)) }] as const;
|
||||
}),
|
||||
);
|
||||
return { models: new Set(decisionEntries.map(([key]) => key)), providers };
|
||||
}
|
||||
|
||||
export const decisionModelsSublabel = (catalog: DecisionCatalog, litellmProvider: string): string | undefined => {
|
||||
const names = catalog.providers.get(litellmProvider)?.names ?? [];
|
||||
return names.length > 0 ? `Decision models: ${names.join(", ")}` : undefined;
|
||||
};
|
||||
|
||||
export const isDecisionSelection = (
|
||||
catalog: DecisionCatalog,
|
||||
litellmProvider: string | undefined,
|
||||
selectedModels: readonly string[],
|
||||
): boolean =>
|
||||
(litellmProvider !== undefined && catalog.providers.get(litellmProvider)?.decisionOnly === true) ||
|
||||
selectedModels.some((model) => catalog.models.has(model));
|
||||
Loading…
Add table
Reference in a new issue