feat(llm): integrate API keys with provider settings

This commit is contained in:
Elian Doran
2026-03-29 22:46:07 +03:00
parent efbe7e0a21
commit ed8b9cc943
4 changed files with 121 additions and 28 deletions

View File

@@ -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) {

View File

@@ -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<string, () => LlmProvider> = {
anthropic: () => new AnthropicProvider()
// Future providers can be added here
};
let cachedProviders: Record<string, LlmProvider> = {};
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<string, (apiKey: string) => LlmProvider> = {
anthropic: (apiKey) => new AnthropicProvider(apiKey)
};
/** Cache of instantiated providers by their config ID */
let cachedProviders: Record<string, LlmProvider> = {};
/**
* 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 = {};
}

View File

@@ -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<typeof streamText>[0] = {
@@ -193,7 +193,7 @@ export class AnthropicProvider implements LlmProvider {
const tools: Record<string, unknown> = {};
if (config.enableWebSearch) {
tools.web_search = anthropic.tools.webSearch_20250305({
tools.web_search = this.anthropic.tools.webSearch_20250305({
maxUses: 5
});
}

View File

@@ -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 }
];
/**