Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions app/store/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
3 changes: 2 additions & 1 deletion app/store/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
13 changes: 13 additions & 0 deletions app/utils/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<modelName>@<modelName>". 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;
}
104 changes: 104 additions & 0 deletions test/custom-model.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});