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>
This commit is contained in:
mateo 2026-10-11 06:22:18 +00:00
parent 218add2e51
commit 4a93216a3a
5 changed files with 72 additions and 65 deletions

View file

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

View file

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

View file

@ -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<typeof fetch>[]) =>
calls.map(([, init]) => Object.values((init?.headers ?? {}) as Record<string, string>));
@ -220,10 +218,7 @@ describe("SystemOneUI integration", () => {
render(<SystemOneUI accessToken="session-key" />);
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 () => {

View file

@ -196,11 +196,6 @@ describe("/v1/decisions requests", () => {
describe("fetchDecisionModels", () => {
const mockFetch = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>();
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();
});

View file

@ -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<string[]> {
const client = proxyClient(customBaseUrl);
const options = { headers: authHeaders(accessToken), signal };
const [deployments, groups] = await Promise.all([
client.get<unknown>("/model/info", options),
client.get<unknown>("/model_group/info", options),
]);
const deploymentNames = modelInfoSchema
.parse(deployments)
const body = await proxyClient(customBaseUrl).get<unknown>("/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<T>(