From 8facf4aca21c71af68efc7b1c005e945de465297 Mon Sep 17 00:00:00 2001 From: Rick <26716961+Bytechoreographer@users.noreply.github.com> Date: Sat, 10 Oct 2026 09:10:14 +0800 Subject: [PATCH] fix(ui): resolve the provider dropdown key to the slug the backend declares (#43707) * 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> * chore(ui): drop explanatory comments from provider slug fix --------- Co-authored-by: AaronHowell <237895480@qq.com> Co-authored-by: ryan-crabbe-berri --- .../handle_add_model_submit.test.tsx | 26 +++++++ .../add_model/handle_add_model_submit.tsx | 10 +-- .../components/provider_info_helpers.test.tsx | 73 +++++++++++++++++++ .../src/components/provider_info_helpers.tsx | 5 +- 4 files changed, 106 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 6190b6aef78..0208acc841f 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,32 @@ 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 () => { + 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 7de8605e92f..728f95a2225 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,17 @@ import { getProviderModels, providerLogoMap, provider_map, + resolveLitellmProviderSlug, } from "./provider_info_helpers"; +const PROVIDER_CREATE_FIELDS: { provider: string; litellm_provider: string }[] = JSON.parse( + readFileSync(resolve(__dirname, "../../../../litellm/proxy/public_endpoints/provider_create_fields.json"), "utf8"), +); + +const BUNDLED_MODEL_MAP: Record = JSON.parse( + readFileSync(resolve(__dirname, "../../../../model_prices_and_context_window.json"), "utf8"), +); + describe("provider_info_helpers", () => { it("maps Microsoft 365 Copilot to its chat model provider and placeholder", () => { expect(provider_map.MICROSOFT_365_COPILOT).toBe("microsoft_365_copilot"); @@ -364,6 +375,40 @@ describe("provider_info_helpers", () => { }); }); + describe("resolveLitellmProviderSlug", () => { + it("should resolve every provider the backend serves to the slug that backend declares", () => { + 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", () => { + 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 = {}; @@ -587,6 +632,34 @@ 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", () => { + 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", () => { + 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", () => { + 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 fca4e5bc74d..fa774d46f0f 100644 --- a/ui/litellm-dashboard/src/components/provider_info_helpers.tsx +++ b/ui/litellm-dashboard/src/components/provider_info_helpers.tsx @@ -318,6 +318,9 @@ export const provider_map: Record = { const standaloneSubproviderSlugs = new Set(["bedrock_mantle"]); +export const resolveLitellmProviderSlug = (provider: string): string => + provider_map[provider] ?? provider.toLowerCase(); + export const providerLogoMap: Partial> = { [Providers.A2A_Agent]: a2aAgentLogo.src, [Providers.AI21]: ai21Logo.src, @@ -487,7 +490,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 = [];