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:
moyai-devin-berriai[bot] 2026-10-09 18:13:21 -07:00 • committed by GitHub
parent 8facf4aca2
commit e2276ac2b9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 1135 additions and 162 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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" />);

View file

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

View file

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

View file

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

View file

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

View file

@ -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) {

View file

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

View file

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

View file

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

View file

@ -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() {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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&apos;re collecting suggestions for cost optimization improvements across routing, budgets, and more. Let us
know what you&apos;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;

View 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);
});
});

View 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));