fix(ui): resolve the provider dropdown key to the slug the backend declares

The Add Model form submits the `provider` field from /public/providers/fields
verbatim, but provider_map is keyed by hand on the frontend, so the two sides
disagree for 14 of the 124 providers the backend serves. Some differ only in
case ("MINIMAX" vs "MiniMax", "CURSOR" vs "Cursor"), others have no key at all
("MILVUS", "LANGFUSE"). getProviderModels looked the key up, got undefined,
matched no models, and the model field silently degraded from a dropdown into
a free-text input with no candidates

Both call sites now go through resolveLitellmProviderSlug, which prefers an
exact provider_map key and otherwise lowercases the value. That resolves every
provider in the catalog to the litellm_provider slug the backend declares for
it. Matching provider_map case-insensitively instead would look tempting and be
wrong: "SAGEMAKER" and "SageMaker" are two distinct providers with two distinct
slugs, and a case-insensitive lookup collapses both onto sagemaker_chat

The regression test drives the real provider_create_fields.json rather than a
fixture, so a provider added to the catalog with a mismatched key fails here
instead of reaching users as an empty dropdown

Co-authored-by: AaronHowell <237895480@qq.com>
This commit is contained in:
Rick 2026-09-29 18:18:36 +08:00
parent 7f95b5f361
commit 42cdabf2ad
4 changed files with 138 additions and 8 deletions

View file

@ -54,6 +54,35 @@ describe("prepareModelAddRequest", () => {
expect(deployment.litellmParamsObj.custom_llm_provider).toBe("petals");
});
it("sends the backend's own slug for a provider whose key is spelled differently in provider_map", async () => {
// The Provider dropdown submits the backend's `provider` field verbatim, and
// "CURSOR" is not a provider_map key. The lowercase fallback has to carry it,
// or the deployment is created against a provider litellm cannot resolve.
const formValues = {
model_mappings: [{ public_name: "Composer", litellm_model: "cursor/composer-1" }],
model_name: "cursor/composer-1",
custom_llm_provider: "CURSOR",
};
const deployments = await prepareModelAddRequest({ ...formValues }, "token", null);
expect(deployments![0].litellmParamsObj.custom_llm_provider).toBe("cursor");
});
it("builds the wildcard model from the backend slug, not the raw dropdown key", async () => {
const formValues = {
model: ["all-wildcard"],
custom_llm_provider: "CURSOR",
};
const deployments = await prepareModelAddRequest({ ...formValues }, "token", null);
expect(deployments).toHaveLength(1);
const [deployment] = deployments!;
expect(deployment.modelName).toBe("cursor/*");
expect(deployment.litellmParamsObj.model).toBe("cursor/*");
});
it("ignores litellm_credential_name inside LiteLLM Params JSON", async () => {
const formValues = {
model_mappings: [

View file

@ -1,6 +1,6 @@
import { toast } from "@/lib/toast";
import { Model, modelCreateCall } from "../networking";
import { provider_map } from "../provider_info_helpers";
import { resolveLitellmProviderSlug } from "../provider_info_helpers";
import { ptuPickerToUtcIso } from "../../utils/ptuDatetime";
export const prepareModelAddRequest = async (formValues: Record<string, any>, accessToken: string, form: any) => {
@ -14,9 +14,7 @@ export const prepareModelAddRequest = async (formValues: Record<string, any>, ac
// Handle wildcard case
if (formValues["model"] && formValues["model"].includes("all-wildcard")) {
const customProviderKey = formValues["custom_llm_provider"] as string;
const mappedProvider =
provider_map[customProviderKey as keyof typeof provider_map] ?? customProviderKey.toLowerCase();
const litellm_custom_provider = mappedProvider;
const litellm_custom_provider = resolveLitellmProviderSlug(customProviderKey);
const wildcardModel = litellm_custom_provider + "/*";
formValues["model_name"] = wildcardModel;
modelMappings.push({
@ -101,9 +99,7 @@ export const prepareModelAddRequest = async (formValues: Record<string, any>, ac
if (key == "model_name") {
litellmParamsObj["model"] = value;
} else if (key == "custom_llm_provider") {
const providerKey = value as string;
const mappingResult = provider_map[providerKey as keyof typeof provider_map] ?? providerKey.toLowerCase();
litellmParamsObj["custom_llm_provider"] = mappingResult;
litellmParamsObj["custom_llm_provider"] = resolveLitellmProviderSlug(value as string);
} else if (key == "model") {
continue;
}

View file

@ -1,4 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { readFileSync } from "fs";
import { resolve } from "path";
import {
Providers,
getPlaceholder,
@ -6,8 +8,20 @@ import {
getProviderModels,
providerLogoMap,
provider_map,
resolveLitellmProviderSlug,
} from "./provider_info_helpers";
// The real catalog the Add Model dropdown is populated from, so a provider
// added there flows into these expectations instead of going unnoticed.
const PROVIDER_CREATE_FIELDS: { provider: string; litellm_provider: string }[] = JSON.parse(
readFileSync(resolve(__dirname, "../../../../litellm/proxy/public_endpoints/provider_create_fields.json"), "utf8"),
);
// The bundled cost map a default install serves the Add Model model list from.
const BUNDLED_MODEL_MAP: Record<string, { litellm_provider?: string }> = JSON.parse(
readFileSync(resolve(__dirname, "../../../../model_prices_and_context_window.json"), "utf8"),
);
describe("provider_info_helpers", () => {
describe("getProviderLogoAndName", () => {
it("should return empty logo and dash display name when providerValue is empty", () => {
@ -318,6 +332,47 @@ describe("provider_info_helpers", () => {
});
});
describe("resolveLitellmProviderSlug", () => {
it("should resolve every provider the backend serves to the slug that backend declares", () => {
// The dropdown passes the backend's `provider` field straight through, so
// any provider whose key is spelled differently on the two sides (or is
// missing from provider_map entirely) silently resolves to the wrong slug
// and empties the model dropdown.
const misresolved = PROVIDER_CREATE_FIELDS.filter(
(entry) => resolveLitellmProviderSlug(entry.provider) !== entry.litellm_provider,
).map(
(entry) =>
`${entry.provider} -> ${resolveLitellmProviderSlug(entry.provider)} (want ${entry.litellm_provider})`,
);
expect(misresolved).toEqual([]);
});
it("should resolve providers the backend spells in caps and provider_map spells in camel case", () => {
expect(resolveLitellmProviderSlug("MINIMAX")).toBe("minimax");
expect(resolveLitellmProviderSlug("CURSOR")).toBe("cursor");
expect(resolveLitellmProviderSlug("RUNWAYML")).toBe("runwayml");
});
it("should resolve providers absent from provider_map to their lowercased value", () => {
expect(resolveLitellmProviderSlug("MILVUS")).toBe("milvus");
expect(resolveLitellmProviderSlug("LANGFUSE")).toBe("langfuse");
expect(resolveLitellmProviderSlug("LITELLM_PROXY")).toBe("litellm_proxy");
});
it("should keep SAGEMAKER on the plain slug rather than the chat variant SageMaker maps to", () => {
// "SAGEMAKER" and "SageMaker" are two distinct backend providers with two
// distinct slugs. Resolving case-insensitively against provider_map would
// collapse them and send sagemaker_chat for both.
expect(resolveLitellmProviderSlug("SAGEMAKER")).toBe("sagemaker");
expect(resolveLitellmProviderSlug("SageMaker")).toBe("sagemaker_chat");
});
it("should prefer an exact provider_map key over the lowercase fallback", () => {
expect(resolveLitellmProviderSlug("Vertex_AI")).toBe("vertex_ai");
expect(resolveLitellmProviderSlug("Google_AI_Studio")).toBe("gemini");
});
});
describe("getProviderModels", () => {
it("should return empty array when provider is not provided", () => {
const modelMap = {};
@ -517,6 +572,45 @@ describe("provider_info_helpers", () => {
expect(result).toEqual([]);
});
it("should populate models for a provider whose backend key is spelled differently from its provider_map key", () => {
// Selecting "Cursor" in the dropdown passes the backend key "CURSOR",
// which is not a provider_map key. Before the slug fallback this resolved
// to undefined, matched nothing, and degraded the model field into a
// free-text input with no candidates.
const modelMap = {
"cursor/composer-1": { litellm_provider: "cursor" },
"gpt-4": { litellm_provider: "openai" },
};
const result = getProviderModels("CURSOR" as Providers, modelMap);
expect(result).toEqual(["cursor/composer-1"]);
});
it("should populate models for SAGEMAKER, whose key is absent from provider_map", () => {
// "SAGEMAKER" (slug "sagemaker") and "SageMaker" (slug "sagemaker_chat")
// are two distinct backend providers. Only the latter is a provider_map
// key, so before the slug fallback the former matched nothing at all.
const modelMap = {
"sagemaker-base": { litellm_provider: "sagemaker" },
"gpt-4": { litellm_provider: "openai" },
};
expect(getProviderModels("SAGEMAKER" as Providers, modelMap)).toEqual(["sagemaker-base"]);
});
it("should populate MiniMax's bundled models from the real cost map", () => {
// Selecting MiniMax passes the backend key "MINIMAX", not the provider_map
// key "MiniMax", so the model field showed no candidates for any MiniMax
// model. Reading the bundled map means this checks what a default install
// actually offers rather than a hand-written fixture.
const minimaxModels = Object.keys(BUNDLED_MODEL_MAP).filter(
(key) => BUNDLED_MODEL_MAP[key]?.litellm_provider === "minimax",
);
expect(minimaxModels.length).toBeGreaterThan(0);
const result = getProviderModels("MINIMAX" as Providers, BUNDLED_MODEL_MAP);
expect([...result].sort()).toEqual([...minimaxModels].sort());
});
it("should handle multiple providers correctly", () => {
const modelMap = {
"gpt-3.5-turbo": { litellm_provider: "openai" },

View file

@ -306,6 +306,17 @@ export const provider_map: Record<string, string> = {
const standaloneSubproviderSlugs = new Set<string>(["bedrock_mantle"]);
// The Add Model dropdown hands us the `provider` field from
// /public/providers/fields, which is not always a provider_map key: the backend
// spells eleven of them differently ("MINIMAX" vs "MiniMax", "CURSOR" vs
// "Cursor") or has no key at all ("MILVUS", "LANGFUSE"). For every provider the
// backend currently serves, the lowercased value is the litellm slug, so use it
// when the key misses. Resolving case-insensitively against provider_map
// instead would be wrong: "SAGEMAKER" would hit the "SageMaker" key and yield
// "sagemaker_chat" rather than the "sagemaker" the backend asked for.
export const resolveLitellmProviderSlug = (provider: string): string =>
provider_map[provider] ?? provider.toLowerCase();
export const providerLogoMap: Partial<Record<Providers, string>> = {
[Providers.A2A_Agent]: a2aAgentLogo.src,
[Providers.AI21]: ai21Logo.src,
@ -467,7 +478,7 @@ export const getPlaceholder = (selectedProvider: string): string => {
export const getProviderModels = (provider: string, modelMap: any): Array<string> => {
let providerKey = provider;
let custom_llm_provider = provider_map[providerKey];
let custom_llm_provider = providerKey ? resolveLitellmProviderSlug(providerKey) : undefined;
let providerModels: Array<string> = [];