This commit is contained in:
Rick 2026-09-30 16:55:53 -04:00 • committed by GitHub
commit f0e65e2e2c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
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> = [];