From 09660f73e8ba67372fc8bf3622a2764c166fb8ca Mon Sep 17 00:00:00 2001 From: mateo Date: Sat, 10 Oct 2026 22:31:00 +0000 Subject: [PATCH] fix(ui): list playground decision models by their supported endpoints Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../playground/llm_calls/system_one.test.ts | 43 +++++++++++++++---- .../playground/llm_calls/system_one.ts | 23 ++++++---- .../src/lib/decisionModels.ts | 11 +++-- 3 files changed, 57 insertions(+), 20 deletions(-) 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 720152d3143..c37af290d99 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 @@ -195,7 +195,12 @@ describe("/v1/decisions requests", () => { 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; + const modelInfo = (data: unknown) => ({ ok: true, text: async () => JSON.stringify({ data }) }) as Response; + const deployment = (model_name: string, mode: string | null, supported_endpoints?: string[]) => ({ + model_name, + litellm_params: { model: `openai/${model_name}` }, + model_info: { id: `${model_name}-${mode}`, mode, supported_endpoints }, + }); beforeEach(() => { vi.stubGlobal("fetch", mockFetch); @@ -208,11 +213,11 @@ describe("fetchDecisionModels", () => { 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 }, + modelInfo([ + deployment("pplx-decider", "evaluation"), + deployment("gpt-5.5", "chat", ["/v1/chat/completions", "/v1/responses"]), + deployment("jev-latest", "evaluation"), + deployment("unknown-mode", null), ]), ); @@ -221,7 +226,7 @@ describe("fetchDecisionModels", () => { "pplx-decider", ]); expect(mockFetch).toHaveBeenCalledWith( - "https://custom.example.com/model_group/info", + "https://custom.example.com/model/info", expect.objectContaining({ method: "GET", headers: expect.objectContaining({ Authorization: "Bearer virtual-key" }), @@ -229,8 +234,28 @@ describe("fetchDecisionModels", () => { ); }); - it("rejects a response that is not a model group list", async () => { - mockFetch.mockResolvedValueOnce(modelGroupInfo([{ mode: "evaluation" }])); + it("lists a chat-mode model whose cost map entry serves a decision endpoint", async () => { + mockFetch.mockResolvedValueOnce( + modelInfo([ + deployment("gpt-6-luna", "chat", ["/v1/chat/completions", "/v1/responses", "/v1/decisions"]), + deployment("clef", "chat", ["/v1/systemone"]), + deployment("gpt-5.5", "chat", ["/v1/chat/completions"]), + ]), + ); + + await expect(fetchDecisionModels("virtual-key")).resolves.toEqual(["clef", "gpt-6-luna"]); + }); + + it("lists a model group once when several deployments share its name", async () => { + mockFetch.mockResolvedValueOnce( + modelInfo([deployment("jev-latest", "evaluation"), deployment("jev-latest", "evaluation")]), + ); + + await expect(fetchDecisionModels("virtual-key")).resolves.toEqual(["jev-latest"]); + }); + + it("rejects a response that is not a model list", async () => { + mockFetch.mockResolvedValueOnce(modelInfo([{ model_info: { 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 f49874b4735..1236ca5b4f7 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 @@ -2,7 +2,7 @@ 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 { isDecisionModel } from "@/lib/decisionModels"; import { systemOneResponseSchema, type PlaygroundRequest, @@ -25,8 +25,15 @@ export type PlaygroundDecisionRequest = | { endpoint: "/v1/systemone"; payload: PlaygroundRequest } | { endpoint: "/v1/decisions"; payload: OpenAIDecisionsRequest }; -const modelGroupInfoSchema = z.object({ - data: z.array(z.object({ model_group: z.string(), mode: z.string().nullish() })), +const modelInfoSchema = z.object({ + data: z.array( + z.object({ + model_name: z.string(), + model_info: z + .object({ mode: z.string().nullish(), supported_endpoints: z.array(z.string()).nullish() }) + .nullish(), + }), + ), }); function proxyClient(customBaseUrl?: string) { @@ -50,15 +57,15 @@ export async function fetchDecisionModels( customBaseUrl?: string, signal?: AbortSignal, ): Promise { - const body = await proxyClient(customBaseUrl).get("/model_group/info", { + const body = await proxyClient(customBaseUrl).get("/model/info", { headers: authHeaders(accessToken), signal, }); - return modelGroupInfoSchema + const decisionModelNames = modelInfoSchema .parse(body) - .data.filter((group) => isDecisionMode(group.mode)) - .map((group) => group.model_group) - .sort((a, b) => a.localeCompare(b)); + .data.filter((deployment) => isDecisionModel(deployment.model_info ?? {})) + .map((deployment) => deployment.model_name); + return [...new Set(decisionModelNames)].sort((a, b) => a.localeCompare(b)); } async function sendDecisionRequest( diff --git a/ui/litellm-dashboard/src/lib/decisionModels.ts b/ui/litellm-dashboard/src/lib/decisionModels.ts index 51de8d62ace..cef29e9893b 100644 --- a/ui/litellm-dashboard/src/lib/decisionModels.ts +++ b/ui/litellm-dashboard/src/lib/decisionModels.ts @@ -27,7 +27,12 @@ export const EMPTY_DECISION_CATALOG: DecisionCatalog = { models: new Set(), prov export const isDecisionMode = (mode: string | null | undefined): boolean => mode === "evaluation"; -const isDecisionEntry = (entry: CostMapEntry): boolean => +export interface DecisionModelFields { + readonly mode?: string | null; + readonly supported_endpoints?: readonly string[] | null; +} + +export const isDecisionModel = (entry: DecisionModelFields): boolean => isDecisionMode(entry.mode) || (entry.supported_endpoints ?? []).some((endpoint) => DECISION_ENDPOINTS.has(endpoint)); const parsedEntries = (costMap: unknown): ReadonlyArray => { @@ -43,12 +48,12 @@ const parsedEntries = (costMap: unknown): ReadonlyArray isDecisionEntry(entry)); + const decisionEntries = entries.filter(([, entry]) => isDecisionModel(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); - return [provider, { decisionOnly: providerEntries.every(([, entry]) => isDecisionEntry(entry)) }] as const; + return [provider, { decisionOnly: providerEntries.every(([, entry]) => isDecisionModel(entry)) }] as const; }), ); return { models: new Set(decisionEntries.map(([key]) => key)), providers };