Skip to content
Open
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
4 changes: 4 additions & 0 deletions apps/client/src/translations/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -2689,6 +2689,10 @@
"base_url": "Base URL",
"base_url_description": "Optional. Override the default API endpoint — useful for self-hosted models (Ollama, LM Studio, vLLM) or proxies.",
"base_url_invalid": "Base URL must be a valid http:// or https:// URL",
"model": "Model",
"model_description": "Optional. Specify a model ID to use with this provider (e.g. gpt-4.1, claude-sonnet-4). Falls back to the provider default if left blank.",
"model_placeholder": "e.g. gpt-4.1",
"default_model": "Default",
"cancel": "Cancel",
"mcp_title": "MCP (Model Context Protocol)",
"mcp_enabled": "MCP server",
Expand Down
6 changes: 5 additions & 1 deletion apps/client/src/translations/ru/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -2466,7 +2466,11 @@
"mcp_endpoint_description": "Добавьте этот URL в настройки MCP вашего ИИ-ассистента",
"base_url_invalid": "Базовый URL должен быть допустимым URL-адресом http:// или https://",
"base_url_description": "(опционально) Переопределите конечную точку API по умолчанию — полезно для локальных провайдеров (Ollama, LM Studio, vLLM) или прокси-серверов.",
"base_url": "Базовый URL"
"base_url": "Базовый URL",
"model": "Модель",
"model_description": "Опционально. Укажите идентификатор модели для этого провайдера (например, gpt-4.1, claude-sonnet-4). Если не задано, будет использована модель по умолчанию.",
"model_placeholder": "например, gpt-4.1",
"default_model": "По умолчанию"
},
"database": {
"title": "База данных"
Expand Down
2 changes: 2 additions & 0 deletions apps/client/src/widgets/type_widgets/options/llm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ function ProviderList({ providers, onDelete }: ProviderListProps) {
<tr>
<th>{t("llm.provider_name")}</th>
<th>{t("llm.provider_type")}</th>
<th>{t("llm.model")}</th>
<th>{t("llm.actions")}</th>
</tr>
</thead>
Expand All @@ -146,6 +147,7 @@ function ProviderList({ providers, onDelete }: ProviderListProps) {
<tr key={provider.id}>
<td>{provider.name}</td>
<td>{providerType?.name || provider.provider}</td>
<td>{provider.model || <em className="text-muted">{t("llm.default_model")}</em>}</td>
<td>
<ActionButton
icon="bx bx-trash"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export interface LlmProviderConfig {
provider: string;
apiKey: string;
baseURL?: string;
model?: string;
}

export interface ProviderType {
Expand Down Expand Up @@ -48,6 +49,7 @@ export default function AddProviderModal({ show, onHidden, onSave }: AddProvider
const [selectedProvider, setSelectedProvider] = useState(PROVIDER_TYPES[0].id);
const [apiKey, setApiKey] = useState("");
const [baseUrl, setBaseUrl] = useState("");
const [model, setModel] = useState("");
const formRef = useRef<HTMLFormElement>(null);

const providerType = useMemo(
Expand All @@ -63,12 +65,14 @@ export default function AddProviderModal({ show, onHidden, onSave }: AddProvider
return;
}

const trimmedModel = model.trim();
const newProvider: LlmProviderConfig = {
id: `${selectedProvider}_${Date.now()}`,
name: providerType?.name || selectedProvider,
provider: selectedProvider,
apiKey: apiKey.trim(),
...(trimmedBaseUrl && { baseURL: trimmedBaseUrl })
...(trimmedBaseUrl && { baseURL: trimmedBaseUrl }),
...(trimmedModel && { model: trimmedModel })
};

onSave(newProvider);
Expand All @@ -80,6 +84,7 @@ export default function AddProviderModal({ show, onHidden, onSave }: AddProvider
setSelectedProvider(PROVIDER_TYPES[0].id);
setApiKey("");
setBaseUrl("");
setModel("");
}

function handleCancel() {
Expand Down Expand Up @@ -134,6 +139,19 @@ export default function AddProviderModal({ show, onHidden, onSave }: AddProvider
/>
</FormGroup>

<FormGroup
name="model"
label={t("llm.model")}
description={t("llm.model_description")}
>
<FormTextBox
type="text"
currentValue={model}
onChange={setModel}
placeholder={t("llm.model_placeholder")}
/>
</FormGroup>

<FormGroup name="api-key" label={t("llm.api_key")}>
<FormTextBox
type="password"
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/routes/api/llm_chat.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const state = vi.hoisted(() => ({
vi.mock("../../services/llm/index.js", () => ({
hasConfiguredProviders: () => state.configured,
getAllModels: () => state.models,
getProviderSetupByType: () => undefined,
getProviderByType: () => ({
chat: () => { if (state.chatThrows) throw new Error("provider exploded"); return {}; },
getAvailableModels: () => state.availableModels,
Expand Down
8 changes: 5 additions & 3 deletions apps/server/src/routes/api/llm_chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { LlmMessage } from "@triliumnext/commons";
import type { Request, Response } from "express";

import { generateChatTitle } from "../../services/llm/chat_title.js";
import { getAllModels, getProviderByType, hasConfiguredProviders, type LlmProviderConfig } from "../../services/llm/index.js";
import { getAllModels, getProviderByType, getProviderSetupByType, hasConfiguredProviders, type LlmProviderConfig } from "../../services/llm/index.js";
import { streamToChunks } from "../../services/llm/stream.js";
import { getLog } from "@triliumnext/core";
import { safeExtractMessageAndStackFromError } from "../../services/utils.js";
Expand Down Expand Up @@ -51,10 +51,12 @@ async function streamChat(req: Request, res: Response) {
}

const provider = getProviderByType(config.provider || "anthropic");
const result = provider.chat(messages, config);
const setup = getProviderSetupByType(config.provider || "anthropic");
const effectiveModel = config.model || setup?.model;
const result = provider.chat(messages, { ...config, model: effectiveModel });

// Get pricing and display name for the model
const modelId = config.model || provider.getAvailableModels().find(m => m.isDefault)?.id;
const modelId = effectiveModel || provider.getAvailableModels().find(m => m.isDefault)?.id;
if (!modelId) {
res.write(`data: ${JSON.stringify({ type: "error", error: "No model specified and no default model available for the provider." })}\n\n`);
return;
Expand Down
42 changes: 42 additions & 0 deletions apps/server/src/services/llm/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
getAllModels,
getProvider,
getProviderByType,
getProviderSetupByType,
hasConfiguredProviders
} from "./index.js";

Expand Down Expand Up @@ -158,5 +159,46 @@ describe("llm/index provider registry", () => {
expect(getAllModels()).toEqual([]);
expect(errorMock).toHaveBeenCalled();
});

it("injects a configured custom model as default when it is not in the provider list", () => {
setProviders([
{ id: "a1", name: "A1", provider: "anthropic", apiKey: "k1", model: "custom-model" }
]);
const models = getAllModels();
expect(models).toHaveLength(2);
expect(models[0]).toEqual({
id: "custom-model",
name: "custom-model",
provider: "anthropic",
pricing: { input: 0, output: 0 },
isDefault: true
});
});

it("marks an existing model as default when it matches the configured model", () => {
setProviders([
{ id: "a1", name: "A1", provider: "anthropic", apiKey: "k1", model: "anthropic-model" }
]);
const models = getAllModels();
expect(models).toHaveLength(1);
expect(models[0]).toMatchObject({
id: "anthropic-model",
provider: "anthropic",
isDefault: true
});
});
});

describe("getProviderSetupByType", () => {
it("returns the setup for the first provider of the given type", () => {
setProviders(TWO);
const setup = getProviderSetupByType("openai");
expect(setup).toMatchObject({ id: "o1", provider: "openai" });
});

it("returns undefined when no provider of that type exists", () => {
setProviders(TWO);
expect(getProviderSetupByType("google")).toBeUndefined();
});
});
});
37 changes: 35 additions & 2 deletions apps/server/src/services/llm/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export interface LlmProviderSetup {
apiKey: string;
/** Optional override for the SDK's default API endpoint (e.g. for self-hosted Ollama, vLLM, or proxies). */
baseURL?: string;
/** Optional model ID to use instead of the provider default. */
model?: string;
}

/** Factory functions for creating provider instances */
Expand Down Expand Up @@ -94,6 +96,14 @@ export function getProviderByType(providerType: string): LlmProvider {
return getProvider(config.id);
}

/**
* Get the setup config for the first configured provider of a specific type.
*/
export function getProviderSetupByType(providerType: string): LlmProviderSetup | undefined {
const configs = getConfiguredProviders();
return configs.find(c => c.provider === providerType);
}

/**
* Check if any providers are configured.
*/
Expand All @@ -119,8 +129,31 @@ export function getAllModels(): ModelInfo[] {
try {
const provider = getProvider(config.id);
const models = provider.getAvailableModels();
for (const model of models) {
allModels.push({ ...model, provider: config.provider });
const mergedModels = models.map(m => ({ ...m, provider: config.provider }));

if (config.model) {
const existingIndex = mergedModels.findIndex(m => m.id === config.model);
if (existingIndex >= 0) {
mergedModels[existingIndex] = { ...mergedModels[existingIndex], isDefault: true };
} else {
mergedModels.unshift({
id: config.model,
name: config.model,
provider: config.provider,
pricing: { input: 0, output: 0 },
isDefault: true
});
}
// Ensure only the configured model is the default
for (let i = 0; i < mergedModels.length; i++) {
if (mergedModels[i].isDefault && mergedModels[i].id !== config.model) {
mergedModels[i] = { ...mergedModels[i], isDefault: false };
}
}
}
Comment thread
Halone228 marked this conversation as resolved.

for (const model of mergedModels) {
allModels.push(model);
}
} catch (e) {
getLog().error(`Failed to get models from provider ${config.provider}: ${e}`);
Expand Down