fix(ui): list playground decision models by their supported endpoints

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
mateo 2026-10-10 22:31:00 +00:00
parent ff60d27b60
commit 09660f73e8
3 changed files with 57 additions and 20 deletions

View file

@ -195,7 +195,12 @@ describe("/v1/decisions requests", () => {
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;
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();
});

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 } 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<string[]> {
const body = await proxyClient(customBaseUrl).get<unknown>("/model_group/info", {
const body = await proxyClient(customBaseUrl).get<unknown>("/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<T>(

View file

@ -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<readonly [string, CostMapEntry]> => {
@ -43,12 +48,12 @@ const parsedEntries = (costMap: unknown): ReadonlyArray<readonly [string, CostMa
export function buildDecisionCatalog(costMap: unknown): DecisionCatalog {
const entries = parsedEntries(costMap);
const decisionEntries = entries.filter(([, entry]) => 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 };