From 09660f73e8ba67372fc8bf3622a2764c166fb8ca Mon Sep 17 00:00:00 2001 From: mateo Date: Sat, 10 Oct 2026 22:31:00 +0000 Subject: [PATCH 1/7] 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 }; From 00cb4d0f010ac75e8aea2fc461aa39cb88eaaff5 Mon Sep 17 00:00:00 2001 From: mateo Date: Sat, 10 Oct 2026 22:41:54 +0000 Subject: [PATCH 2/7] test(ui): feed the Decisions playground integration test /model/info deployments Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../SystemOneUI.integration.test.tsx | 53 +++++++++++-------- 1 file changed, 31 insertions(+), 22 deletions(-) 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 eb75c836b2b..33aee764fbb 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 @@ -15,15 +15,15 @@ import SystemOneUI, { NOUL_HELP } from "./SystemOneUI"; import type { SystemOneResponse } from "./lib/schemas"; import type { OpenAIDecisionsResponse } from "./lib/openAIDecisions"; -interface ModelGroupInfo { - model_group: string; +interface Deployment { + model_name: 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 DECISION_AND_CHAT_MODELS: Deployment[] = [ + { model_name: "gpt-5.5", mode: "chat" }, + { model_name: "jev-latest", mode: "evaluation" }, + { model_name: "pplx-decider", mode: "evaluation" }, ]; const responseBody: SystemOneResponse = { @@ -45,8 +45,19 @@ const render = (ui: ReactElement) => , ); -const modelGroupInfoResponse = (groups: ModelGroupInfo[]) => - ({ ok: true, status: 200, text: async () => JSON.stringify({ data: groups }) }) as Response; +const modelInfoResponse = (deployments: Deployment[]) => + ({ + ok: true, + status: 200, + text: async () => + JSON.stringify({ + data: deployments.map(({ model_name, mode }) => ({ + model_name, + litellm_params: { model: `openai/${model_name}` }, + model_info: { id: model_name, mode }, + })), + }), + }) as Response; const EXPIRED_KEY_RESPONSE = { ok: false, @@ -55,7 +66,7 @@ const EXPIRED_KEY_RESPONSE = { 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 isModelLookup = (input: RequestInfo | URL) => String(input).endsWith("/model/info"); const lookupAuthHeaders = (calls: Parameters[]) => calls.map(([, init]) => Object.values((init?.headers ?? {}) as Record)); @@ -96,7 +107,7 @@ describe("SystemOneUI integration", () => { isModelLookup(input) ? mockModelLookup(input, init) : mockFetch(input, init), ); mockFetch.mockResolvedValue(createResponse(responseBody)); - mockModelLookup.mockResolvedValue(modelGroupInfoResponse([])); + mockModelLookup.mockResolvedValue(modelInfoResponse([])); }); afterEach(() => { @@ -143,7 +154,7 @@ describe("SystemOneUI integration", () => { }); it("keeps the preset label after picking a model and leaves an omitted model out when clearing", async () => { - mockModelLookup.mockResolvedValue(modelGroupInfoResponse(DECISION_AND_CHAT_MODELS)); + mockModelLookup.mockResolvedValue(modelInfoResponse(DECISION_AND_CHAT_MODELS)); render(); await waitFor(() => expect(screen.getByRole("combobox", { name: "Decision model" })).toHaveValue("jev-latest")); const picker = screen.getByRole("combobox", { name: "Example" }); @@ -210,7 +221,7 @@ describe("SystemOneUI integration", () => { 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)); + mockModelLookup.mockResolvedValue(modelInfoResponse(DECISION_AND_CHAT_MODELS)); render(); await waitFor(() => expect(screen.getByRole("combobox", { name: "Decision model" })).toHaveValue("jev-latest")); @@ -228,7 +239,7 @@ describe("SystemOneUI integration", () => { it("sends each endpoint's real shape and switches existing responses between cards and JSON without resending", async () => { const user = userEvent.setup(); - mockModelLookup.mockResolvedValue(modelGroupInfoResponse(DECISION_AND_CHAT_MODELS)); + mockModelLookup.mockResolvedValue(modelInfoResponse(DECISION_AND_CHAT_MODELS)); render(); await waitFor(() => expect(screen.getByRole("combobox", { name: "Decision model" })).toHaveValue("jev-latest")); const systemOneEditor = await openJsonEditor(); @@ -519,7 +530,7 @@ describe("SystemOneUI integration", () => { 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)); + mockModelLookup.mockResolvedValue(modelInfoResponse(DECISION_AND_CHAT_MODELS)); render(); const picker = await screen.findByRole("combobox", { name: "Decision model" }); @@ -542,9 +553,7 @@ describe("SystemOneUI integration", () => { it("routes a configured TypeSafe model through the same proxy endpoint", async () => { const user = userEvent.setup(); - mockModelLookup.mockResolvedValue( - modelGroupInfoResponse([{ model_group: "typesafe/jev-latest", mode: "evaluation" }]), - ); + mockModelLookup.mockResolvedValue(modelInfoResponse([{ model_name: "typesafe/jev-latest", mode: "evaluation" }])); render(); await waitFor(() => expect(screen.getByRole("combobox", { name: "Decision model" })).toHaveValue("typesafe/jev-latest"), @@ -573,7 +582,7 @@ describe("SystemOneUI integration", () => { 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)); + mockModelLookup.mockResolvedValue(modelInfoResponse(DECISION_AND_CHAT_MODELS)); render(); await user.type(screen.getByLabelText("Virtual Key", { exact: true }), "sk-typed-key"); @@ -606,7 +615,7 @@ describe("SystemOneUI integration", () => { fireEvent.change(editor, { target: { value: draft } }); await act(async () => { - models.resolve(modelGroupInfoResponse(DECISION_AND_CHAT_MODELS)); + models.resolve(modelInfoResponse(DECISION_AND_CHAT_MODELS)); await new Promise((resolve) => setTimeout(resolve, 0)); }); @@ -635,7 +644,7 @@ describe("SystemOneUI integration", () => { expect(mockFetch.mock.calls[0]?.[0]).toMatch(new RegExp(`${endpoint}$`)); expect(JSON.parse(mockFetch.mock.calls[0]?.[1]?.body as string)).not.toHaveProperty("model"); await act(async () => { - models.resolve(modelGroupInfoResponse(DECISION_AND_CHAT_MODELS)); + models.resolve(modelInfoResponse(DECISION_AND_CHAT_MODELS)); await models.promise; }); expect(screen.getByRole("combobox", { name: "Decision model" })).toHaveValue(""); @@ -676,7 +685,7 @@ describe("SystemOneUI integration", () => { it("uses the requested model when the response omits it", async () => { const user = userEvent.setup(); - mockModelLookup.mockResolvedValue(modelGroupInfoResponse(DECISION_AND_CHAT_MODELS)); + mockModelLookup.mockResolvedValue(modelInfoResponse(DECISION_AND_CHAT_MODELS)); mockFetch.mockResolvedValueOnce( createResponse({ answers: responseBody.answers, @@ -984,7 +993,7 @@ describe("SystemOneUI integration", () => { it("edits score levels and keeps instructions when a question changes type", async () => { const user = userEvent.setup(); - mockModelLookup.mockResolvedValue(modelGroupInfoResponse(DECISION_AND_CHAT_MODELS)); + mockModelLookup.mockResolvedValue(modelInfoResponse(DECISION_AND_CHAT_MODELS)); render(); await waitFor(() => expect(screen.getByRole("combobox", { name: "Decision model" })).toHaveValue("jev-latest")); From c852de84bae91e7c4d9bfa9192df5528c0c8ca25 Mon Sep 17 00:00:00 2001 From: mateo Date: Sat, 10 Oct 2026 23:40:01 +0000 Subject: [PATCH 3/7] fix(ui): look decision models up again each time the Decisions playground opens Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../SystemOneUI.integration.test.tsx | 19 +++++++++++++++++++ .../systemOneUI/useDecisionModels.ts | 1 + 2 files changed, 20 insertions(+) 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 33aee764fbb..e600c1b469b 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 @@ -551,6 +551,25 @@ describe("SystemOneUI integration", () => { expect(JSON.parse(mockFetch.mock.calls[0]?.[1]?.body as string).model).toBe("pplx-decider"); }); + it("looks the decision models up again when the playground is reopened within the cache window", async () => { + mockModelLookup.mockResolvedValueOnce(modelInfoResponse([{ model_name: "gpt-5.5", mode: "chat" }])); + const queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false } } }); + const page = ( + + + + ); + const first = rtlRender(page); + expect(await screen.findByRole("link", { name: "Add a decision model" })).toBeInTheDocument(); + first.unmount(); + + mockModelLookup.mockResolvedValueOnce(modelInfoResponse(DECISION_AND_CHAT_MODELS)); + rtlRender(page); + const picker = await screen.findByRole("combobox", { name: "Decision model" }); + await waitFor(() => expect(picker).toHaveValue("jev-latest")); + expect(mockModelLookup).toHaveBeenCalledTimes(2); + }); + it("routes a configured TypeSafe model through the same proxy endpoint", async () => { const user = userEvent.setup(); mockModelLookup.mockResolvedValue(modelInfoResponse([{ model_name: "typesafe/jev-latest", mode: "evaluation" }])); 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 index c694b6384e5..51477554f18 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/useDecisionModels.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/useDecisionModels.ts @@ -26,6 +26,7 @@ export function useDecisionModels( enabled: lookupApiKey !== "", retry: false, staleTime: 60 * 1000, + refetchOnMount: "always" as const, }; const query = useQuery(queryOptions); return { decisionModels: query.data ?? NO_DECISION_MODELS, isLoaded: query.isSuccess }; From 218add2e5112a198a0da7e04ac33a61fd8812ce5 Mon Sep 17 00:00:00 2001 From: mateo Date: Sun, 11 Oct 2026 06:11:41 +0000 Subject: [PATCH 4/7] fix(ui): keep decision routing groups in the playground picker /model/info lists deployments and aliases only, so the picker also reads /model_group/info for the routing groups that serve decision models Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../SystemOneUI.integration.test.tsx | 18 ++++-- .../playground/llm_calls/system_one.test.ts | 61 +++++++++++-------- .../playground/llm_calls/system_one.ts | 26 +++++--- 3 files changed, 68 insertions(+), 37 deletions(-) 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 9cfc375aada..d27416540af 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 @@ -54,6 +54,8 @@ const modelInfoResponse = (deployments: Deployment[]) => JSON.stringify({ data: deployments.map(({ model_name, mode }) => ({ model_name, + model_group: model_name, + mode, litellm_params: { model: `openai/${model_name}` }, model_info: { id: model_name, mode }, })), @@ -67,7 +69,7 @@ const EXPIRED_KEY_RESPONSE = { JSON.stringify({ error: { message: "Authentication Error - Expired Key.", type: "expired_key", code: "401" } }), } as Response; -const isModelLookup = (input: RequestInfo | URL) => String(input).endsWith("/model/info"); +const isModelLookup = (input: RequestInfo | URL) => /\/model(_group)?\/info$/.test(String(input)); const lookupAuthHeaders = (calls: Parameters[]) => calls.map(([, init]) => Object.values((init?.headers ?? {}) as Record)); @@ -218,7 +220,10 @@ describe("SystemOneUI integration", () => { render(); await waitFor(() => expect(screen.getByRole("combobox", { name: "Decision model" })).toHaveValue("jev-latest")); - expect(lookupAuthHeaders(mockModelLookup.mock.calls)).toEqual([expect.arrayContaining(["Bearer session-key"])]); + expect(lookupAuthHeaders(mockModelLookup.mock.calls)).toEqual([ + expect.arrayContaining(["Bearer session-key"]), + expect.arrayContaining(["Bearer session-key"]), + ]); await user.click(screen.getByRole("button", { name: "Send" })); expect(await screen.findByText("Selected choice")).toBeInTheDocument(); @@ -474,11 +479,11 @@ describe("SystemOneUI integration", () => { expect(await screen.findByRole("link", { name: "Add a decision model" })).toBeInTheDocument(); first.unmount(); - mockModelLookup.mockResolvedValueOnce(modelInfoResponse(DECISION_AND_CHAT_MODELS)); + mockModelLookup.mockResolvedValue(modelInfoResponse(DECISION_AND_CHAT_MODELS)); rtlRender(page); const picker = await screen.findByRole("combobox", { name: "Decision model" }); await waitFor(() => expect(picker).toHaveValue("jev-latest")); - expect(mockModelLookup).toHaveBeenCalledTimes(2); + expect(mockModelLookup).toHaveBeenCalledTimes(4); }); it("routes a configured TypeSafe model through the same proxy endpoint", async () => { @@ -518,7 +523,10 @@ describe("SystemOneUI integration", () => { await user.type(screen.getByLabelText("Virtual Key", { exact: true }), "sk-typed-key"); await waitFor(() => expect(screen.getByRole("combobox", { name: "Decision model" })).toHaveValue("jev-latest")); - expect(lookupAuthHeaders(mockModelLookup.mock.calls)).toEqual([expect.arrayContaining(["Bearer sk-typed-key"])]); + expect(lookupAuthHeaders(mockModelLookup.mock.calls)).toEqual([ + expect.arrayContaining(["Bearer sk-typed-key"]), + expect.arrayContaining(["Bearer sk-typed-key"]), + ]); }); it("keeps the admin signed in when a typed virtual key has expired", async () => { 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 c37af290d99..11f87a11c0a 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 @@ -196,6 +196,11 @@ describe("/v1/decisions requests", () => { describe("fetchDecisionModels", () => { const mockFetch = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise>(); const modelInfo = (data: unknown) => ({ ok: true, text: async () => JSON.stringify({ data }) }) as Response; + const group = (model_group: string, mode: string | null) => ({ model_group, mode }); + const serve = (deployments: unknown[], groups: unknown[] = []) => + mockFetch.mockImplementation(async (input) => + String(input).endsWith("/model_group/info") ? modelInfo(groups) : modelInfo(deployments), + ); const deployment = (model_name: string, mode: string | null, supported_endpoints?: string[]) => ({ model_name, litellm_params: { model: `openai/${model_name}` }, @@ -212,50 +217,58 @@ describe("fetchDecisionModels", () => { }); it("lists the key's decision models from the proxy that answers the request, sorted by name", async () => { - mockFetch.mockResolvedValueOnce( - modelInfo([ - deployment("pplx-decider", "evaluation"), - deployment("gpt-5.5", "chat", ["/v1/chat/completions", "/v1/responses"]), - deployment("jev-latest", "evaluation"), - deployment("unknown-mode", null), - ]), - ); + serve([ + deployment("pplx-decider", "evaluation"), + deployment("gpt-5.5", "chat", ["/v1/chat/completions", "/v1/responses"]), + deployment("jev-latest", "evaluation"), + deployment("unknown-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/info", - expect.objectContaining({ - method: "GET", - headers: expect.objectContaining({ Authorization: "Bearer virtual-key" }), - }), + for (const path of ["/model/info", "/model_group/info"]) { + expect(mockFetch).toHaveBeenCalledWith( + `https://custom.example.com${path}`, + expect.objectContaining({ + method: "GET", + headers: expect.objectContaining({ Authorization: "Bearer virtual-key" }), + }), + ); + } + }); + + it("lists a routing group whose members serve decisions, next to the deployments", async () => { + serve( + [deployment("decision-primary", "evaluation")], + [group("decision-balanced", "evaluation"), group("chat-pool", "chat"), group("unknown-mode", null)], ); + + await expect(fetchDecisionModels("virtual-key")).resolves.toEqual(["decision-balanced", "decision-primary"]); }); 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"]), - ]), - ); + serve([ + 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")]), + serve( + [deployment("jev-latest", "evaluation"), deployment("jev-latest", "evaluation")], + [group("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" } }])); + serve([{ 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 1236ca5b4f7..3ac9286d89c 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 { isDecisionModel } from "@/lib/decisionModels"; +import { isDecisionMode, isDecisionModel } from "@/lib/decisionModels"; import { systemOneResponseSchema, type PlaygroundRequest, @@ -36,6 +36,10 @@ const modelInfoSchema = z.object({ ), }); +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; @@ -57,15 +61,21 @@ export async function fetchDecisionModels( customBaseUrl?: string, signal?: AbortSignal, ): Promise { - const body = await proxyClient(customBaseUrl).get("/model/info", { - headers: authHeaders(accessToken), - signal, - }); - const decisionModelNames = modelInfoSchema - .parse(body) + const client = proxyClient(customBaseUrl); + const options = { headers: authHeaders(accessToken), signal }; + const [deployments, groups] = await Promise.all([ + client.get("/model/info", options), + client.get("/model_group/info", options), + ]); + const deploymentNames = modelInfoSchema + .parse(deployments) .data.filter((deployment) => isDecisionModel(deployment.model_info ?? {})) .map((deployment) => deployment.model_name); - return [...new Set(decisionModelNames)].sort((a, b) => a.localeCompare(b)); + const groupNames = modelGroupInfoSchema + .parse(groups) + .data.filter((group) => isDecisionMode(group.mode)) + .map((group) => group.model_group); + return [...new Set([...deploymentNames, ...groupNames])].sort((a, b) => a.localeCompare(b)); } async function sendDecisionRequest( From 4a93216a3a8712e4c30e066c0b132425df26ecf3 Mon Sep 17 00:00:00 2001 From: mateo Date: Sun, 11 Oct 2026 06:22:18 +0000 Subject: [PATCH 5/7] fix(proxy): surface routing group rows in /model/info so the playground lists them A routing group is callable by its own name but /model/info only listed deployments and aliases, so the Decisions picker could not show a group over a decision-capable member and had to read /model_group/info, which carries only the aggregate mode and fails the picker when it errors. The proxy now materializes routing group rows into /model/info and the picker goes back to one lookup filtered by isDecisionModel Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/proxy_server.py | 2 + .../proxy_server/test_routes_model_info.py | 24 +++++++ .../SystemOneUI.integration.test.tsx | 18 ++--- .../playground/llm_calls/system_one.test.ts | 67 +++++++++---------- .../playground/llm_calls/system_one.ts | 26 +++---- 5 files changed, 72 insertions(+), 65 deletions(-) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 089647c6297..740259775ee 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -16401,6 +16401,8 @@ async def model_info_v1( all_models: list[dict] = copy.deepcopy(llm_router.model_list) alias_models: Final = copy.deepcopy(llm_router.get_model_list_from_model_alias()) all_models.extend(alias_models) + routing_group_models: Final = copy.deepcopy(llm_router.get_model_list_from_routing_groups()) + all_models.extend(routing_group_models) all_models = expand_wildcard_deployments_for_model_info(all_models) diff --git a/tests/unit/proxy/proxy_server/test_routes_model_info.py b/tests/unit/proxy/proxy_server/test_routes_model_info.py index fae4a6470cf..9ab4e1d0974 100644 --- a/tests/unit/proxy/proxy_server/test_routes_model_info.py +++ b/tests/unit/proxy/proxy_server/test_routes_model_info.py @@ -596,6 +596,30 @@ def test_model_info_team_key_cannot_see_other_teams_byok_model( assert _BYOK_INTERNAL_NAME not in surfaced_names +@pytest.mark.parametrize("path", ["/v1/model/info", "/model/info"]) +def test_model_info_lists_routing_group_rows(client, auth_as, byok_team_router, monkeypatch, path): + """A routing group is callable by its own name, so /model/info surfaces the + rows the router materializes for it next to plain deployments and aliases. + """ + from litellm.proxy._types import LitellmUserRoles + + group_row = { + "model_name": "decision-balanced", + "litellm_params": {"model": "openai/gpt-6-luna"}, + "model_info": {"id": "luna-deployment-id", "db_model": True, "mode": "chat"}, + } + byok_team_router.get_model_list_from_routing_groups = MagicMock(return_value=[group_row]) + monkeypatch.setattr(proxy_server, "prisma_client", None) + + with auth_as(role=LitellmUserRoles.PROXY_ADMIN, user_id="admin"): + response = client.get(path) + + assert response.status_code == 200 + surfaced = {m["model_name"]: m for m in response.json()["data"]} + assert surfaced["decision-balanced"]["model_info"]["mode"] == "chat" + byok_team_router.get_model_list_from_routing_groups.assert_called_once_with() + + # --------------------------------------------------------------------------- # GET /model_group/info # --------------------------------------------------------------------------- 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 d27416540af..9cfc375aada 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 @@ -54,8 +54,6 @@ const modelInfoResponse = (deployments: Deployment[]) => JSON.stringify({ data: deployments.map(({ model_name, mode }) => ({ model_name, - model_group: model_name, - mode, litellm_params: { model: `openai/${model_name}` }, model_info: { id: model_name, mode }, })), @@ -69,7 +67,7 @@ const EXPIRED_KEY_RESPONSE = { JSON.stringify({ error: { message: "Authentication Error - Expired Key.", type: "expired_key", code: "401" } }), } as Response; -const isModelLookup = (input: RequestInfo | URL) => /\/model(_group)?\/info$/.test(String(input)); +const isModelLookup = (input: RequestInfo | URL) => String(input).endsWith("/model/info"); const lookupAuthHeaders = (calls: Parameters[]) => calls.map(([, init]) => Object.values((init?.headers ?? {}) as Record)); @@ -220,10 +218,7 @@ describe("SystemOneUI integration", () => { render(); await waitFor(() => expect(screen.getByRole("combobox", { name: "Decision model" })).toHaveValue("jev-latest")); - expect(lookupAuthHeaders(mockModelLookup.mock.calls)).toEqual([ - expect.arrayContaining(["Bearer session-key"]), - expect.arrayContaining(["Bearer session-key"]), - ]); + 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(); @@ -479,11 +474,11 @@ describe("SystemOneUI integration", () => { expect(await screen.findByRole("link", { name: "Add a decision model" })).toBeInTheDocument(); first.unmount(); - mockModelLookup.mockResolvedValue(modelInfoResponse(DECISION_AND_CHAT_MODELS)); + mockModelLookup.mockResolvedValueOnce(modelInfoResponse(DECISION_AND_CHAT_MODELS)); rtlRender(page); const picker = await screen.findByRole("combobox", { name: "Decision model" }); await waitFor(() => expect(picker).toHaveValue("jev-latest")); - expect(mockModelLookup).toHaveBeenCalledTimes(4); + expect(mockModelLookup).toHaveBeenCalledTimes(2); }); it("routes a configured TypeSafe model through the same proxy endpoint", async () => { @@ -523,10 +518,7 @@ describe("SystemOneUI integration", () => { await user.type(screen.getByLabelText("Virtual Key", { exact: true }), "sk-typed-key"); await waitFor(() => expect(screen.getByRole("combobox", { name: "Decision model" })).toHaveValue("jev-latest")); - expect(lookupAuthHeaders(mockModelLookup.mock.calls)).toEqual([ - expect.arrayContaining(["Bearer sk-typed-key"]), - expect.arrayContaining(["Bearer sk-typed-key"]), - ]); + 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 () => { 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 11f87a11c0a..ae5637f4f27 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 @@ -196,11 +196,6 @@ describe("/v1/decisions requests", () => { describe("fetchDecisionModels", () => { const mockFetch = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise>(); const modelInfo = (data: unknown) => ({ ok: true, text: async () => JSON.stringify({ data }) }) as Response; - const group = (model_group: string, mode: string | null) => ({ model_group, mode }); - const serve = (deployments: unknown[], groups: unknown[] = []) => - mockFetch.mockImplementation(async (input) => - String(input).endsWith("/model_group/info") ? modelInfo(groups) : modelInfo(deployments), - ); const deployment = (model_name: string, mode: string | null, supported_endpoints?: string[]) => ({ model_name, litellm_params: { model: `openai/${model_name}` }, @@ -217,58 +212,62 @@ describe("fetchDecisionModels", () => { }); it("lists the key's decision models from the proxy that answers the request, sorted by name", async () => { - serve([ - deployment("pplx-decider", "evaluation"), - deployment("gpt-5.5", "chat", ["/v1/chat/completions", "/v1/responses"]), - deployment("jev-latest", "evaluation"), - deployment("unknown-mode", null), - ]); + mockFetch.mockResolvedValueOnce( + modelInfo([ + deployment("pplx-decider", "evaluation"), + deployment("gpt-5.5", "chat", ["/v1/chat/completions", "/v1/responses"]), + deployment("jev-latest", "evaluation"), + deployment("unknown-mode", null), + ]), + ); await expect(fetchDecisionModels("virtual-key", "https://custom.example.com/")).resolves.toEqual([ "jev-latest", "pplx-decider", ]); - for (const path of ["/model/info", "/model_group/info"]) { - expect(mockFetch).toHaveBeenCalledWith( - `https://custom.example.com${path}`, - expect.objectContaining({ - method: "GET", - headers: expect.objectContaining({ Authorization: "Bearer virtual-key" }), - }), - ); - } + expect(mockFetch).toHaveBeenCalledWith( + "https://custom.example.com/model/info", + expect.objectContaining({ + method: "GET", + headers: expect.objectContaining({ Authorization: "Bearer virtual-key" }), + }), + ); }); - it("lists a routing group whose members serve decisions, next to the deployments", async () => { - serve( - [deployment("decision-primary", "evaluation")], - [group("decision-balanced", "evaluation"), group("chat-pool", "chat"), group("unknown-mode", null)], + it("lists a routing group row the proxy materializes over a default-mode decision deployment", async () => { + mockFetch.mockResolvedValueOnce( + modelInfo([ + deployment("gpt-6-luna", "chat", ["/v1/chat/completions", "/v1/decisions"]), + deployment("decision-balanced", "chat", ["/v1/chat/completions", "/v1/decisions"]), + deployment("chat-pool", "chat", ["/v1/chat/completions"]), + ]), ); - await expect(fetchDecisionModels("virtual-key")).resolves.toEqual(["decision-balanced", "decision-primary"]); + await expect(fetchDecisionModels("virtual-key")).resolves.toEqual(["decision-balanced", "gpt-6-luna"]); }); it("lists a chat-mode model whose cost map entry serves a decision endpoint", async () => { - serve([ - 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"]), - ]); + 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 () => { - serve( - [deployment("jev-latest", "evaluation"), deployment("jev-latest", "evaluation")], - [group("jev-latest", "evaluation")], + 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 () => { - serve([{ model_info: { mode: "evaluation" } }]); + 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 3ac9286d89c..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, isDecisionModel } from "@/lib/decisionModels"; +import { isDecisionModel } from "@/lib/decisionModels"; import { systemOneResponseSchema, type PlaygroundRequest, @@ -36,10 +36,6 @@ const modelInfoSchema = z.object({ ), }); -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; @@ -61,21 +57,15 @@ export async function fetchDecisionModels( customBaseUrl?: string, signal?: AbortSignal, ): Promise { - const client = proxyClient(customBaseUrl); - const options = { headers: authHeaders(accessToken), signal }; - const [deployments, groups] = await Promise.all([ - client.get("/model/info", options), - client.get("/model_group/info", options), - ]); - const deploymentNames = modelInfoSchema - .parse(deployments) + const body = await proxyClient(customBaseUrl).get("/model/info", { + headers: authHeaders(accessToken), + signal, + }); + const decisionModelNames = modelInfoSchema + .parse(body) .data.filter((deployment) => isDecisionModel(deployment.model_info ?? {})) .map((deployment) => deployment.model_name); - const groupNames = modelGroupInfoSchema - .parse(groups) - .data.filter((group) => isDecisionMode(group.mode)) - .map((group) => group.model_group); - return [...new Set([...deploymentNames, ...groupNames])].sort((a, b) => a.localeCompare(b)); + return [...new Set(decisionModelNames)].sort((a, b) => a.localeCompare(b)); } async function sendDecisionRequest( From 0fe1ce0e974bcb8a456687197aff53ac9df3afb8 Mon Sep 17 00:00:00 2001 From: mateo Date: Sun, 11 Oct 2026 06:35:45 +0000 Subject: [PATCH 6/7] fix(proxy): keep /model/info routing group rows inside the basedpyright ceiling Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/proxy_server.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 740259775ee..d8ada7441f2 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -16400,9 +16400,8 @@ async def model_info_v1( # when v1 resolved models only via public model_name strings. all_models: list[dict] = copy.deepcopy(llm_router.model_list) alias_models: Final = copy.deepcopy(llm_router.get_model_list_from_model_alias()) - all_models.extend(alias_models) - routing_group_models: Final = copy.deepcopy(llm_router.get_model_list_from_routing_groups()) - all_models.extend(routing_group_models) + routing_group_models: Final = copy.deepcopy(list(llm_router.get_model_list_from_routing_groups())) + all_models.extend([*alias_models, *routing_group_models]) all_models = expand_wildcard_deployments_for_model_info(all_models) From 7bdcf728da5dfbf185c8bcfc365f039a6f08378c Mon Sep 17 00:00:00 2001 From: mateo Date: Sun, 11 Oct 2026 07:02:50 +0000 Subject: [PATCH 7/7] test(proxy): materialize the /model/info routing group row through a configured Router Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../proxy_server/test_routes_model_info.py | 31 +++++++++++++------ 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/tests/unit/proxy/proxy_server/test_routes_model_info.py b/tests/unit/proxy/proxy_server/test_routes_model_info.py index 9ab4e1d0974..14a3d394fc0 100644 --- a/tests/unit/proxy/proxy_server/test_routes_model_info.py +++ b/tests/unit/proxy/proxy_server/test_routes_model_info.py @@ -597,27 +597,40 @@ def test_model_info_team_key_cannot_see_other_teams_byok_model( @pytest.mark.parametrize("path", ["/v1/model/info", "/model/info"]) -def test_model_info_lists_routing_group_rows(client, auth_as, byok_team_router, monkeypatch, path): +def test_model_info_lists_routing_group_rows(client, auth_as, monkeypatch, path): """A routing group is callable by its own name, so /model/info surfaces the - rows the router materializes for it next to plain deployments and aliases. + rows a configured router materializes for it next to the member deployment. """ from litellm.proxy._types import LitellmUserRoles - group_row = { - "model_name": "decision-balanced", - "litellm_params": {"model": "openai/gpt-6-luna"}, - "model_info": {"id": "luna-deployment-id", "db_model": True, "mode": "chat"}, + member = { + "model_name": "gpt-6-luna", + "litellm_params": {"model": "openai/gpt-6-luna", "api_key": "sk-test"}, + "model_info": {"id": "luna-deployment-id", "mode": "chat"}, } - byok_team_router.get_model_list_from_routing_groups = MagicMock(return_value=[group_row]) + router = litellm.Router( + model_list=[member], + routing_groups=[ + { + "group_name": "decision-balanced", + "models": ["gpt-6-luna"], + "routing_strategy": "simple-shuffle", + } + ], + ) + monkeypatch.setattr(proxy_server, "llm_router", router) + monkeypatch.setattr(proxy_server, "llm_model_list", [member]) + monkeypatch.setattr(proxy_server, "user_model", None) monkeypatch.setattr(proxy_server, "prisma_client", None) with auth_as(role=LitellmUserRoles.PROXY_ADMIN, user_id="admin"): response = client.get(path) - assert response.status_code == 200 + assert response.status_code == 200, response.text surfaced = {m["model_name"]: m for m in response.json()["data"]} + assert set(surfaced) == {"gpt-6-luna", "decision-balanced"} + assert surfaced["decision-balanced"]["model_info"]["id"] == "luna-deployment-id" assert surfaced["decision-balanced"]["model_info"]["mode"] == "chat" - byok_team_router.get_model_list_from_routing_groups.assert_called_once_with() # ---------------------------------------------------------------------------