From 42cdabf2adf92ef772be3d52baeae6359c96afe2 Mon Sep 17 00:00:00 2001 From: Rick <26716961+Bytechoreographer@users.noreply.github.com> Date: Tue, 29 Sep 2026 18:18:36 +0800 Subject: [PATCH] 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> --- .../handle_add_model_submit.test.tsx | 29 ++++++ .../add_model/handle_add_model_submit.tsx | 10 +- .../components/provider_info_helpers.test.tsx | 94 +++++++++++++++++++ .../src/components/provider_info_helpers.tsx | 13 ++- 4 files changed, 138 insertions(+), 8 deletions(-) diff --git a/ui/litellm-dashboard/src/components/add_model/handle_add_model_submit.test.tsx b/ui/litellm-dashboard/src/components/add_model/handle_add_model_submit.test.tsx index 923cf2aa0e3..c03ce216909 100644 --- a/ui/litellm-dashboard/src/components/add_model/handle_add_model_submit.test.tsx +++ b/ui/litellm-dashboard/src/components/add_model/handle_add_model_submit.test.tsx @@ -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: [ diff --git a/ui/litellm-dashboard/src/components/add_model/handle_add_model_submit.tsx b/ui/litellm-dashboard/src/components/add_model/handle_add_model_submit.tsx index 41133958c0a..5f2747e0415 100644 --- a/ui/litellm-dashboard/src/components/add_model/handle_add_model_submit.tsx +++ b/ui/litellm-dashboard/src/components/add_model/handle_add_model_submit.tsx @@ -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, accessToken: string, form: any) => { @@ -14,9 +14,7 @@ export const prepareModelAddRequest = async (formValues: Record, 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, 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; } diff --git a/ui/litellm-dashboard/src/components/provider_info_helpers.test.tsx b/ui/litellm-dashboard/src/components/provider_info_helpers.test.tsx index 29ce2d4865a..a5003c23580 100644 --- a/ui/litellm-dashboard/src/components/provider_info_helpers.test.tsx +++ b/ui/litellm-dashboard/src/components/provider_info_helpers.test.tsx @@ -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 = 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" }, diff --git a/ui/litellm-dashboard/src/components/provider_info_helpers.tsx b/ui/litellm-dashboard/src/components/provider_info_helpers.tsx index b0e33338bab..8d39bdc8310 100644 --- a/ui/litellm-dashboard/src/components/provider_info_helpers.tsx +++ b/ui/litellm-dashboard/src/components/provider_info_helpers.tsx @@ -306,6 +306,17 @@ export const provider_map: Record = { const standaloneSubproviderSlugs = new Set(["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> = { [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 => { let providerKey = provider; - let custom_llm_provider = provider_map[providerKey]; + let custom_llm_provider = providerKey ? resolveLitellmProviderSlug(providerKey) : undefined; let providerModels: Array = [];