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 = [];