From e2276ac2b94da32d885b7c2a54c6d183fbb6ab0c Mon Sep 17 00:00:00 2001 From: "moyai-devin-berriai[bot]" <336287033+moyai-devin-berriai[bot]@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:13:21 -0700 Subject: [PATCH] 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> --- .../app/(dashboard)/model-hub-table/page.tsx | 12 +- .../page.integration.test.tsx | 2 +- .../models-and-endpoints/page.test.tsx | 33 ++++- .../(dashboard)/models-and-endpoints/page.tsx | 6 +- .../panels/AddModelPanel.integration.test.tsx | 67 ++++++++- .../panels/AddModelPanel.tsx | 3 + .../SystemOneUI.integration.test.tsx | 138 +++++++++++++++++- .../components/systemOneUI/SystemOneUI.tsx | 86 ++++++++--- .../components/systemOneUI/lib/example.ts | 7 +- .../systemOneUI/lib/payloadModel.test.ts | 41 ++++++ .../systemOneUI/lib/payloadModel.ts | 27 ++++ .../systemOneUI/lib/validatePayload.ts | 2 +- .../systemOneUI/useDecisionModels.ts | 32 ++++ .../playground/llm_calls/system_one.test.ts | 45 +++++- .../playground/llm_calls/system_one.ts | 52 +++++-- .../src/app/model_hub_table/page.tsx | 10 +- .../components/AIHub/ModelHubTable.test.tsx | 101 ++++++++++++- .../src/components/AIHub/ModelHubTable.tsx | 55 ++++--- .../AIHub/modelUsageExample.test.ts | 40 +++++ .../src/components/AIHub/modelUsageExample.ts | 47 ++++++ .../AddModelForm.integration.test.tsx | 2 + .../src/components/add_model/AddModelForm.tsx | 13 +- .../add_model/DecisionModelNote.tsx | 54 +++++++ .../DecisionModelsBanner.integration.test.tsx | 71 +++++++++ .../molecules/DecisionModelsBanner.tsx | 78 ++++++++++ ...cost_optimization_feedback_banner.test.tsx | 33 ----- .../cost_optimization_feedback_banner.tsx | 58 -------- .../src/lib/decisionModels.test.ts | 108 ++++++++++++++ .../src/lib/decisionModels.ts | 74 ++++++++++ 29 files changed, 1135 insertions(+), 162 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/lib/payloadModel.test.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/lib/payloadModel.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/useDecisionModels.ts create mode 100644 ui/litellm-dashboard/src/components/AIHub/modelUsageExample.test.ts create mode 100644 ui/litellm-dashboard/src/components/AIHub/modelUsageExample.ts create mode 100644 ui/litellm-dashboard/src/components/add_model/DecisionModelNote.tsx create mode 100644 ui/litellm-dashboard/src/components/molecules/DecisionModelsBanner.integration.test.tsx create mode 100644 ui/litellm-dashboard/src/components/molecules/DecisionModelsBanner.tsx delete mode 100644 ui/litellm-dashboard/src/components/molecules/cost_optimization_feedback_banner.test.tsx delete mode 100644 ui/litellm-dashboard/src/components/molecules/cost_optimization_feedback_banner.tsx create mode 100644 ui/litellm-dashboard/src/lib/decisionModels.test.ts create mode 100644 ui/litellm-dashboard/src/lib/decisionModels.ts diff --git a/ui/litellm-dashboard/src/app/(dashboard)/model-hub-table/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/model-hub-table/page.tsx index 7327d332fbd..c9eb8762ed2 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/model-hub-table/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/model-hub-table/page.tsx @@ -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 ; } - return ; + return ( + + ); } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.integration.test.tsx index 86b5659d570..fa62f43c2d1 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.integration.test.tsx @@ -16,7 +16,7 @@ vi.mock("./panels/ModelRetrySettingsPanel", () => ({ default: () =>
({ default: () =>
})); vi.mock("./panels/PriceDataPanel", () => ({ default: () =>
})); vi.mock("./panels/AccessGroupBudgetsPanel", () => ({ default: () =>
})); -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 }) =>
model:{modelId}
, })); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.test.tsx index fb6ed90c002..55597f4ce82 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.test.tsx @@ -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 }) => ( +
+ {onAddModel && ( + + )} +
+ ), +})); vi.mock("@/components/model_info_view", () => ({ default: ({ modelId }: { modelId: string }) =>
model:{modelId}
, })); @@ -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(); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.tsx index cd2922dff4b..1eae68b9d43 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.tsx @@ -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() {
- + {!isViewOnly && ( + setActiveKey("add") : undefined} /> + )} {modelId ? ( ({ 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"); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AddModelPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AddModelPanel.tsx index 9fbd138bda4..4703bda8d20 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AddModelPanel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AddModelPanel.tsx @@ -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} /> ); } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/SystemOneUI.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/SystemOneUI.integration.test.tsx index 0812e40d3e4..367b779ecf7 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/SystemOneUI.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/SystemOneUI.integration.test.tsx @@ -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) => , ); +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[]) => + calls.map(([, init]) => Object.values((init?.headers ?? {}) as Record)); + 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(); + const mockModelLookup = vi.fn(); 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(); + + 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(); + 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(); + 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(); + + 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(); + + 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(); + mockModelLookup.mockReturnValue(models.promise); + render(); + 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(); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/SystemOneUI.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/SystemOneUI.tsx index e092efce161..3e61030da7b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/SystemOneUI.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/SystemOneUI.tsx @@ -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(disabledPersonalKeyCreation ? "custom" : "session"); const [customApiKey, setCustomApiKey] = useState(""); - const [endpoint, setEndpoint] = useState("/typesafe/v1/systemone"); - const [payloads, setPayloads] = useState>({ - "/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(null); + const endpoint: DecisionEndpoint = + chosenEndpoint ?? (decisionModels.length > 0 ? "/v1/systemone" : "/typesafe/v1/systemone"); + const [drafts, setDrafts] = useState>>({}); + 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(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 = TypeSafe · /typesafe/v1/systemone + {endpoint === "/v1/systemone" && ( + <> + 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} + /> +
+ {decisionModelsLoaded && decisionModels.length === 0 && ( + + Add a decision model + + )} + + )}
@@ -156,11 +202,7 @@ export default function SystemOneUI({ accessToken, disabledPersonalKeyCreation = )}
- @@ -186,8 +228,12 @@ export default function SystemOneUI({ accessToken, disabledPersonalKeyCreation = {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."}{" "} + + How to call /v1/decisions and /v1/systemone + + {" · "} Give us feedback on what you want for decision models diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/lib/example.ts b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/lib/example.ts index 2632b2e1a9b..796275212a1 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/lib/example.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/lib/example.ts @@ -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 }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/lib/payloadModel.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/lib/payloadModel.test.ts new file mode 100644 index 00000000000..6e3ee62b217 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/lib/payloadModel.test.ts @@ -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(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/lib/payloadModel.ts b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/lib/payloadModel.ts new file mode 100644 index 00000000000..d347c7fb3c8 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/lib/payloadModel.ts @@ -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 | 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); +}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/lib/validatePayload.ts b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/lib/validatePayload.ts index ac53f871333..9bed8e9924b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/lib/validatePayload.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/lib/validatePayload.ts @@ -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) { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/useDecisionModels.ts b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/useDecisionModels.ts new file mode 100644 index 00000000000..c694b6384e5 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/useDecisionModels.ts @@ -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 }; +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/system_one.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/system_one.test.ts index c039b018b8f..e097f2d3d26 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/system_one.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/system_one.test.ts @@ -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>(); + 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(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/system_one.ts b/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/system_one.ts index b71e1268ac2..6c29d9110ac 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/system_one.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/system_one.ts @@ -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 { +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 { + return withRequiredHeaders( {}, { "Content-Type": "application/json", [getGlobalLitellmHeaderName()]: `Bearer ${accessToken}`, }, ); - const client = createApiClient({ getBaseUrl: () => normalizedBaseUrl }); - const body = await client.post(endpoint, { body: payload, headers, signal }); +} + +export async function fetchDecisionModels( + accessToken: string, + customBaseUrl?: string, + signal?: AbortSignal, +): Promise { + const body = await proxyClient(customBaseUrl).get("/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 { + const startedAt = performance.now(); + const body = await proxyClient(customBaseUrl).post(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."); diff --git a/ui/litellm-dashboard/src/app/model_hub_table/page.tsx b/ui/litellm-dashboard/src/app/model_hub_table/page.tsx index c399178e6aa..46c7394cff7 100644 --- a/ui/litellm-dashboard/src/app/model_hub_table/page.tsx +++ b/ui/litellm-dashboard/src/app/model_hub_table/page.tsx @@ -15,7 +15,15 @@ function PublicModelHubTableContent() { setAccessToken(key); }, [key]); - return ; + return ( + + ); } export default function PublicModelHubTable() { diff --git a/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.test.tsx b/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.test.tsx index 1f052b8932a..cc3ac1d2591 100644 --- a/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.test.tsx +++ b/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.test.tsx @@ -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(); + renderWithProviders( + , + ); await waitFor(() => { if (shouldRedirect) { @@ -148,7 +157,13 @@ describe("ModelHubTable", () => { }); renderWithProviders( - , + , ); await waitFor(() => { @@ -165,7 +180,9 @@ describe("ModelHubTable", () => { isLoading: false, }); - renderWithProviders(); + renderWithProviders( + , + ); expect(await screen.findByText("No models yet")).toBeInTheDocument(); expect(networking.modelHubCall).not.toHaveBeenCalled(); @@ -191,7 +208,15 @@ describe("ModelHubTable", () => { isLoading: false, }); - renderWithProviders(); + renderWithProviders( + , + ); 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( + , + ); + 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 = Promise.resolve([])) => { vi.mocked(networking.modelHubCall).mockResolvedValue({ @@ -217,7 +302,13 @@ describe("ModelHubTable", () => { const user = userEvent.setup(); renderWithProviders( - , + , ); return { user, search: await screen.findByPlaceholderText("Search model names...") }; }; diff --git a/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx b/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx index c4d776ea2fb..e5dcb8ebc73 100644 --- a/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx +++ b/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx @@ -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 = ({ accessToken, publicPage, premiumUser, userRole }) => { +const ModelHubTable: React.FC = ({ + 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 = ({ accessToken, publicPage,
)} - {/* Usage Example */}

Usage Example

+ {isDecisionMode(selectedModel.mode) && ( +

+ Decision models answer on /v1/decisions and /v1/systemone, not{" "} + /chat/completions.{" "} + + How to call decision models + + {canOpenPlayground && ( + <> + {" · "} + + Try it in the Playground + + + )} +

+ )} - {`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(), + )}
diff --git a/ui/litellm-dashboard/src/components/AIHub/modelUsageExample.test.ts b/ui/litellm-dashboard/src/components/AIHub/modelUsageExample.test.ts new file mode 100644 index 00000000000..25d1b83ebe9 --- /dev/null +++ b/ui/litellm-dashboard/src/components/AIHub/modelUsageExample.test.ts @@ -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"'); + }); +}); diff --git a/ui/litellm-dashboard/src/components/AIHub/modelUsageExample.ts b/ui/litellm-dashboard/src/components/AIHub/modelUsageExample.ts new file mode 100644 index 00000000000..2426ec09942 --- /dev/null +++ b/ui/litellm-dashboard/src/components/AIHub/modelUsageExample.ts @@ -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); diff --git a/ui/litellm-dashboard/src/components/add_model/AddModelForm.integration.test.tsx b/ui/litellm-dashboard/src/components/add_model/AddModelForm.integration.test.tsx index 75297a508d2..067adcaeb56 100644 --- a/ui/litellm-dashboard/src/components/add_model/AddModelForm.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/add_model/AddModelForm.integration.test.tsx @@ -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, }; diff --git a/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx b/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx index fbdab07a545..e8b7ca62c3d 100644 --- a/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx +++ b/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx @@ -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 = ({ setShowAdvancedSettings, teams, credentials, + decisionCatalog, }) => { const [testMode, setTestMode] = useState("chat"); const [isResultModalVisible, setIsResultModalVisible] = useState(false); @@ -99,6 +103,7 @@ const AddModelForm: React.FC = ({ 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(); @@ -168,11 +173,16 @@ const AddModelForm: React.FC = ({ sortedProviderMetadata.map((providerInfo) => ({ label: providerInfo.provider_display_name, value: providerInfo.provider, + sublabel: decisionModelsSublabel(decisionCatalog, providerInfo.litellm_provider), icon: , })), - [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 = ({ providerModels={providerModels} getPlaceholder={getPlaceholder} /> + {/* Conditionally Render "Public Model Name" */} diff --git a/ui/litellm-dashboard/src/components/add_model/DecisionModelNote.tsx b/ui/litellm-dashboard/src/components/add_model/DecisionModelNote.tsx new file mode 100644 index 00000000000..b199facd484 --- /dev/null +++ b/ui/litellm-dashboard/src/components/add_model/DecisionModelNote.tsx @@ -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(); + const model = useWatch({ control, name: "model" }); + + if (!isDecisionSelection(catalog, litellmProvider, selectedModelNames(model))) { + return null; + } + + return ( + + + Decision model + +

+ For decision requests, call /v1/decisions (OpenAI format) or /v1/systemone (System + One format).{" "} + + How to call decision models + +

+

+ After you add it,{" "} + + test it in the System One playground + +

+
+
+ ); +} diff --git a/ui/litellm-dashboard/src/components/molecules/DecisionModelsBanner.integration.test.tsx b/ui/litellm-dashboard/src/components/molecules/DecisionModelsBanner.integration.test.tsx new file mode 100644 index 00000000000..b7d81a09f4e --- /dev/null +++ b/ui/litellm-dashboard/src/components/molecules/DecisionModelsBanner.integration.test.tsx @@ -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(); + 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(); + 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(); + expect(screen.queryByRole("button", { name: "Add a decision model" })).not.toBeInTheDocument(); + + rerender(); + 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(); + expect(screen.getByRole("paragraph")).not.toHaveTextContent("Add Model"); + expect(screen.getByRole("paragraph")).toHaveTextContent("Call them at /v1/decisions or /v1/systemone"); + + rerender(); + + 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(); + expect(screen.getByText("Decision models are now supported")).toBeInTheDocument(); + }); + + it("hides itself and persists the dismissal when the dismiss button is clicked", () => { + render(); + 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(); + expect(screen.queryByText("Decision models are now supported")).not.toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/molecules/DecisionModelsBanner.tsx b/ui/litellm-dashboard/src/components/molecules/DecisionModelsBanner.tsx new file mode 100644 index 00000000000..5dbf5191198 --- /dev/null +++ b/ui/litellm-dashboard/src/components/molecules/DecisionModelsBanner.tsx @@ -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 = ({ onAddModel }) => { + const [dismissed, setDismissed] = useState(() => { + if (typeof window !== "undefined") { + return localStorage.getItem(STORAGE_KEY) === "true"; + } + return false; + }); + + if (dismissed) { + return null; + } + + return ( +
+
+ +
+
+

Decision models are now supported

+

+ {onAddModel && ( + <> + Search decision in Add Model to find them.{" "} + + )} + Call them at /v1/decisions or /v1/systemone, or try them in the System One + playground.{" "} + + How to call them + +

+
+ {onAddModel && ( + + )} + + +
+ ); +}; + +export default DecisionModelsBanner; diff --git a/ui/litellm-dashboard/src/components/molecules/cost_optimization_feedback_banner.test.tsx b/ui/litellm-dashboard/src/components/molecules/cost_optimization_feedback_banner.test.tsx deleted file mode 100644 index 3fc1d41dfdd..00000000000 --- a/ui/litellm-dashboard/src/components/molecules/cost_optimization_feedback_banner.test.tsx +++ /dev/null @@ -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(); - 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(); - 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(); - expect(screen.queryByText("Help shape cost optimization")).not.toBeInTheDocument(); - }); -}); diff --git a/ui/litellm-dashboard/src/components/molecules/cost_optimization_feedback_banner.tsx b/ui/litellm-dashboard/src/components/molecules/cost_optimization_feedback_banner.tsx deleted file mode 100644 index 176789db964..00000000000 --- a/ui/litellm-dashboard/src/components/molecules/cost_optimization_feedback_banner.tsx +++ /dev/null @@ -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 ( -
-
- -
-
-

Help shape cost optimization

-

- We're collecting suggestions for cost optimization improvements across routing, budgets, and more. Let us - know what you'd like to see. -

-
- - -
- ); -}; - -export default CostOptimizationFeedbackBanner; diff --git a/ui/litellm-dashboard/src/lib/decisionModels.test.ts b/ui/litellm-dashboard/src/lib/decisionModels.test.ts new file mode 100644 index 00000000000..c0f3842993c --- /dev/null +++ b/ui/litellm-dashboard/src/lib/decisionModels.test.ts @@ -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); + }); +}); diff --git a/ui/litellm-dashboard/src/lib/decisionModels.ts b/ui/litellm-dashboard/src/lib/decisionModels.ts new file mode 100644 index 00000000000..78499952487 --- /dev/null +++ b/ui/litellm-dashboard/src/lib/decisionModels.ts @@ -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 = 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; + +interface ProviderDecisionModels { + readonly names: readonly string[]; + readonly decisionOnly: boolean; +} + +export interface DecisionCatalog { + readonly models: ReadonlySet; + readonly providers: ReadonlyMap; +} + +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 => { + 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));