diff --git a/app/store/chat.ts b/app/store/chat.ts index 87c1a8beba0..d264b149c23 100644 --- a/app/store/chat.ts +++ b/app/store/chat.ts @@ -166,10 +166,12 @@ function fillTemplateWith(input: string, modelConfig: ModelConfig) { var serviceProvider = "OpenAI"; if (modelInfo) { - // TODO: auto detect the providerName from the modelConfig.model - // Directly use the providerName from the modelInfo serviceProvider = modelInfo.provider.providerName; + } else if (modelConfig.providerName) { + // For custom models not in DEFAULT_MODELS, use the configured providerName + // so that {{ServiceProvider}} in templates reflects the actual provider + serviceProvider = modelConfig.providerName; } const vars = { diff --git a/app/store/config.ts b/app/store/config.ts index 45e21b02697..0dbb448e188 100644 --- a/app/store/config.ts +++ b/app/store/config.ts @@ -17,7 +17,8 @@ import { import { createPersistStore } from "../utils/store"; import type { Voice } from "rt-client"; -export type ModelType = (typeof DEFAULT_MODELS)[number]["name"]; +// Include `string & {}` to allow custom model names while preserving autocomplete for built-in models +export type ModelType = (typeof DEFAULT_MODELS)[number]["name"] | (string & {}); export type TTSModelType = (typeof DEFAULT_TTS_MODELS)[number]; export type TTSVoiceType = (typeof DEFAULT_TTS_VOICES)[number]; export type TTSEngineType = (typeof DEFAULT_TTS_ENGINES)[number]; diff --git a/app/utils/model.ts b/app/utils/model.ts index f460babcd25..a0ecb63bfc0 100644 --- a/app/utils/model.ts +++ b/app/utils/model.ts @@ -254,5 +254,18 @@ export function isModelNotavailableInServer( const fullName = `${modelName}@${providerName.toLowerCase()}`; if (modelTable?.[fullName]?.available === true) return false; } + + // For custom models added without an explicit provider (e.g. just "chatglm3-6b"), + // the table entry key is "@". Check if any available entry + // in the custom model table matches this model name, regardless of provider. + // This ensures user-defined custom models are never incorrectly blocked. + const hasAvailableCustomEntry = Object.values(modelTable).some( + (entry) => + entry.name === modelName && + entry.available === true && + entry.provider?.providerType === "custom", + ); + if (hasAvailableCustomEntry) return false; + return true; } diff --git a/test/custom-model.test.ts b/test/custom-model.test.ts new file mode 100644 index 00000000000..a5eddeeabe9 --- /dev/null +++ b/test/custom-model.test.ts @@ -0,0 +1,104 @@ +/** + * Tests for custom model support (issue #4221). + * + * Verifies that models added via the customModels setting (e.g. "chatglm3-6b") + * are handled correctly throughout the codebase: + * - collectModelTable builds the right entries + * - isModelNotavailableInServer correctly allows custom models + */ + +import { collectModelTable, isModelNotavailableInServer } from "../app/utils/model"; +import { DEFAULT_MODELS } from "../app/constant"; + +describe("collectModelTable with custom models", () => { + test("adds a custom model with provider derived from model name when no @ given", () => { + const table = collectModelTable(DEFAULT_MODELS, "chatglm3-6b"); + const entry = table["chatglm3-6b@chatglm3-6b"]; + expect(entry).toBeDefined(); + expect(entry.name).toBe("chatglm3-6b"); + expect(entry.available).toBe(true); + expect(entry.provider?.providerType).toBe("custom"); + }); + + test("adds a custom model with explicit provider (model@OpenAI format)", () => { + const table = collectModelTable(DEFAULT_MODELS, "chatglm3-6b@OpenAI"); + const entry = table["chatglm3-6b@openai"]; + expect(entry).toBeDefined(); + expect(entry.name).toBe("chatglm3-6b"); + expect(entry.available).toBe(true); + expect(entry.provider?.providerType).toBe("custom"); + }); + + test("custom model is marked unavailable when prefixed with -", () => { + const table = collectModelTable(DEFAULT_MODELS, "-chatglm3-6b"); + // Custom model created with available=false + const entry = table["chatglm3-6b@chatglm3-6b"]; + expect(entry).toBeDefined(); + expect(entry.available).toBe(false); + }); + + test("custom model with display name alias (model=Alias format)", () => { + const table = collectModelTable(DEFAULT_MODELS, "chatglm3-6b=GLM-3"); + const entry = table["chatglm3-6b@chatglm3-6b"]; + expect(entry).toBeDefined(); + expect(entry.displayName).toBe("GLM-3"); + expect(entry.available).toBe(true); + }); +}); + +describe("isModelNotavailableInServer with custom models", () => { + afterEach(() => { + delete process.env.DISABLE_GPT4; + }); + + test("allows a custom model when providerNames includes the model name itself (existing behaviour)", () => { + // This is the pattern used in api/common.ts: third provider is the model name + const result = isModelNotavailableInServer( + "chatglm3-6b", + "chatglm3-6b", + ["OpenAI", "Azure", "chatglm3-6b"], + ); + expect(result).toBe(false); + }); + + test("allows a custom model even when providerNames does NOT include the model name (new fallback)", () => { + // Custom model added to server customModels without explicit provider. + // The request comes in with only standard provider names — the new fallback + // should still allow it because the model's providerType is "custom". + const result = isModelNotavailableInServer( + "chatglm3-6b", + "chatglm3-6b", + ["OpenAI", "Azure"], + ); + expect(result).toBe(false); + }); + + test("blocks a model that is not in customModels and not in DEFAULT_MODELS", () => { + // No custom model configured — completely unknown model should be blocked + // only if customModels is non-empty (the check is skipped when customModels is empty) + const result = isModelNotavailableInServer( + "-all,gpt-4o-mini", // customModels is set, but chatglm3-6b is not listed + "chatglm3-6b", + ["OpenAI", "Azure"], + ); + expect(result).toBe(true); + }); + + test("allows a built-in model when no custom model filter is applied", () => { + const result = isModelNotavailableInServer( + "", + "gpt-4o", + "OpenAI", + ); + expect(result).toBe(false); + }); + + test("custom model added via -all,+model format is allowed", () => { + const result = isModelNotavailableInServer( + "-all,chatglm3-6b", + "chatglm3-6b", + ["OpenAI", "Azure", "chatglm3-6b"], + ); + expect(result).toBe(false); + }); +});