From ed8b9cc943e2f93848afab487aa262aeeebcbdac Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sun, 29 Mar 2026 22:46:07 +0300 Subject: [PATCH] feat(llm): integrate API keys with provider settings --- apps/server/src/routes/api/llm_chat.ts | 19 ++- apps/server/src/services/llm/index.ts | 111 +++++++++++++++--- .../src/services/llm/providers/anthropic.ts | 14 +-- apps/server/src/services/options_init.ts | 5 +- 4 files changed, 121 insertions(+), 28 deletions(-) diff --git a/apps/server/src/routes/api/llm_chat.ts b/apps/server/src/routes/api/llm_chat.ts index 74478b2e30..286c10c950 100644 --- a/apps/server/src/routes/api/llm_chat.ts +++ b/apps/server/src/routes/api/llm_chat.ts @@ -1,7 +1,7 @@ import type { Request, Response } from "express"; import type { LlmMessage } from "@triliumnext/commons"; -import { getProvider, type LlmProviderConfig } from "../../services/llm/index.js"; +import { getProviderByType, hasConfiguredProviders, type LlmProviderConfig } from "../../services/llm/index.js"; import { streamToChunks } from "../../services/llm/stream.js"; interface ChatRequest { @@ -43,7 +43,13 @@ async function streamChat(req: Request, res: Response) { const flushableRes = res as Response & { flush?: () => void }; try { - const provider = getProvider(config.provider || "anthropic"); + if (!hasConfiguredProviders()) { + res.write(`data: ${JSON.stringify({ type: "error", error: "No LLM providers configured. Please add a provider in Options → AI / LLM." })}\n\n`); + res.end(); + return; + } + + const provider = getProviderByType(config.provider || "anthropic"); const result = provider.chat(messages, config); // Get pricing from provider for cost calculation @@ -68,10 +74,15 @@ async function streamChat(req: Request, res: Response) { * Get available models for a provider. */ function getModels(req: Request, res: Response) { - const provider = req.query.provider as string || "anthropic"; + const providerType = req.query.provider as string || "anthropic"; try { - const llmProvider = getProvider(provider); + if (!hasConfiguredProviders()) { + res.status(400).json({ error: "No LLM providers configured. Please add a provider in Options → AI / LLM." }); + return; + } + + const llmProvider = getProviderByType(providerType); const models = llmProvider.getAvailableModels(); res.json({ models }); } catch (error) { diff --git a/apps/server/src/services/llm/index.ts b/apps/server/src/services/llm/index.ts index 2c306d2b59..4d04acb485 100644 --- a/apps/server/src/services/llm/index.ts +++ b/apps/server/src/services/llm/index.ts @@ -1,24 +1,103 @@ import type { LlmProvider } from "./types.js"; import { AnthropicProvider } from "./providers/anthropic.js"; +import optionService from "../options.js"; +import log from "../log.js"; -const providers: Record LlmProvider> = { - anthropic: () => new AnthropicProvider() - // Future providers can be added here -}; - -let cachedProviders: Record = {}; - -export function getProvider(name: string = "anthropic"): LlmProvider { - if (!cachedProviders[name]) { - const factory = providers[name]; - if (!factory) { - throw new Error(`Unknown LLM provider: ${name}. Available: ${Object.keys(providers).join(", ")}`); - } - cachedProviders[name] = factory(); - } - return cachedProviders[name]; +/** + * Configuration for a single LLM provider instance. + * This matches the structure stored in the llmProviders option. + */ +export interface LlmProviderSetup { + id: string; + name: string; + provider: string; + apiKey: string; } +/** Factory functions for creating provider instances */ +const providerFactories: Record LlmProvider> = { + anthropic: (apiKey) => new AnthropicProvider(apiKey) +}; + +/** Cache of instantiated providers by their config ID */ +let cachedProviders: Record = {}; + +/** + * Get configured providers from the options. + */ +function getConfiguredProviders(): LlmProviderSetup[] { + try { + const providersJson = optionService.getOptionOrNull("llmProviders"); + if (!providersJson) { + return []; + } + return JSON.parse(providersJson) as LlmProviderSetup[]; + } catch (e) { + log.error(`Failed to parse llmProviders option: ${e}`); + return []; + } +} + +/** + * Get a provider instance by its configuration ID. + * If no ID is provided, returns the first configured provider. + */ +export function getProvider(providerId?: string): LlmProvider { + const configs = getConfiguredProviders(); + + if (configs.length === 0) { + throw new Error("No LLM providers configured. Please add a provider in Options → AI / LLM."); + } + + // Find the requested provider or use the first one + const config = providerId + ? configs.find(c => c.id === providerId) + : configs[0]; + + if (!config) { + throw new Error(`LLM provider not found: ${providerId}`); + } + + // Check cache + if (cachedProviders[config.id]) { + return cachedProviders[config.id]; + } + + // Create new provider instance + const factory = providerFactories[config.provider]; + if (!factory) { + throw new Error(`Unknown LLM provider type: ${config.provider}. Available: ${Object.keys(providerFactories).join(", ")}`); + } + + const provider = factory(config.apiKey); + cachedProviders[config.id] = provider; + return provider; +} + +/** + * Get the first configured provider of a specific type (e.g., "anthropic"). + */ +export function getProviderByType(providerType: string): LlmProvider { + const configs = getConfiguredProviders(); + const config = configs.find(c => c.provider === providerType); + + if (!config) { + throw new Error(`No ${providerType} provider configured. Please add one in Options → AI / LLM.`); + } + + return getProvider(config.id); +} + +/** + * Check if any providers are configured. + */ +export function hasConfiguredProviders(): boolean { + return getConfiguredProviders().length > 0; +} + +/** + * Clear the provider cache. Call this when provider configurations change. + */ export function clearProviderCache(): void { cachedProviders = {}; } diff --git a/apps/server/src/services/llm/providers/anthropic.ts b/apps/server/src/services/llm/providers/anthropic.ts index a3de4affba..e7d1b42a41 100644 --- a/apps/server/src/services/llm/providers/anthropic.ts +++ b/apps/server/src/services/llm/providers/anthropic.ts @@ -1,4 +1,4 @@ -import { anthropic } from "@ai-sdk/anthropic"; +import { createAnthropic, type AnthropicProvider as AnthropicSDKProvider } from "@ai-sdk/anthropic"; import { streamText, stepCountIs, type CoreMessage } from "ai"; import type { LlmMessage } from "@triliumnext/commons"; @@ -133,13 +133,13 @@ function buildNoteContext(noteId: string): string | null { export class AnthropicProvider implements LlmProvider { name = "anthropic"; + private anthropic: AnthropicSDKProvider; - constructor() { - const apiKey = process.env.ANTHROPIC_API_KEY; + constructor(apiKey: string) { if (!apiKey) { - throw new Error("ANTHROPIC_API_KEY environment variable is required"); + throw new Error("API key is required for Anthropic provider"); } - // The anthropic provider reads ANTHROPIC_API_KEY from env automatically + this.anthropic = createAnthropic({ apiKey }); } chat(messages: LlmMessage[], config: LlmProviderConfig): StreamResult { @@ -162,7 +162,7 @@ export class AnthropicProvider implements LlmProvider { content: m.content })); - const model = anthropic(config.model || DEFAULT_MODEL); + const model = this.anthropic(config.model || DEFAULT_MODEL); // Build options for streamText const streamOptions: Parameters[0] = { @@ -193,7 +193,7 @@ export class AnthropicProvider implements LlmProvider { const tools: Record = {}; if (config.enableWebSearch) { - tools.web_search = anthropic.tools.webSearch_20250305({ + tools.web_search = this.anthropic.tools.webSearch_20250305({ maxUses: 5 }); } diff --git a/apps/server/src/services/options_init.ts b/apps/server/src/services/options_init.ts index a49672019d..43f20e54f2 100644 --- a/apps/server/src/services/options_init.ts +++ b/apps/server/src/services/options_init.ts @@ -209,7 +209,10 @@ const defaultOptions: DefaultOption[] = [ ]), isSynced: true }, - { name: "experimentalFeatures", value: "[]", isSynced: true } + { name: "experimentalFeatures", value: "[]", isSynced: true }, + + // AI / LLM + { name: "llmProviders", value: "[]", isSynced: false } ]; /**