Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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": "По умолчанию",
},
Comment thread
Halone228 marked this conversation as resolved.
Outdated
"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