From 7e514036e82e1d1cf94890f9d559b1c02f7ee2ff Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Mar 2026 21:38:16 +0000 Subject: [PATCH 1/4] Add LM Studio as a local LLM provider Adds LM Studio support using the existing OpenAI SDK with a custom baseURL (http://localhost:1234/v1), since LM Studio exposes a fully OpenAI-compatible REST API. Uses json_object response format (not Structured Outputs) for broad model compatibility, with graceful tool-call fallback mirroring the Ollama provider. Changes: - packages/llm: LMStudioProvider class, updated ProviderType union, SettingsConfig, provider-factory, and index exports - packages/database: add 'lmstudio' to llm_provider enum, add lmstudio_base_url column, migration 0019 - apps/api: new /api/v1/lmstudio/models and /health routes - apps/web: LM Studio provider button, dynamic model list, base URL input in Advanced Settings - .env.example: LM_STUDIO_BASE_URL variable https://claude.ai/code/session_014vPCcYQRHJpXauLWVzqVvG --- .env.example | 2 + apps/api/src/index.ts | 2 + apps/api/src/routes/index.ts | 1 + apps/api/src/routes/lmstudio.ts | 68 +++ apps/web/src/api/client.ts | 21 +- apps/web/src/pages/SettingsPage.tsx | 112 ++++- .../migrations/0019_add_lmstudio_provider.sql | 3 + .../database/migrations/meta/_journal.json | 7 + packages/database/src/schema/settings.ts | 3 +- packages/llm/src/index.ts | 1 + packages/llm/src/provider-factory.ts | 20 +- .../llm/src/providers/lmstudio-provider.ts | 402 ++++++++++++++++++ packages/llm/src/types.ts | 3 +- 13 files changed, 636 insertions(+), 9 deletions(-) create mode 100644 apps/api/src/routes/lmstudio.ts create mode 100644 packages/database/migrations/0019_add_lmstudio_provider.sql create mode 100644 packages/llm/src/providers/lmstudio-provider.ts diff --git a/.env.example b/.env.example index 5226dbd..6974117 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,7 @@ POSTGRES_PORT=5432 OPENAI_API_KEY=sk-... ANTHROPIC_API_KEY=sk-ant-... OLLAMA_BASE_URL=http://localhost:11434 +LM_STUDIO_BASE_URL=http://localhost:1234/v1 # Server Configuration API_PORT=3000 # External port mapping for API (Docker only) @@ -24,5 +25,6 @@ TZ=America/Chicago # - For local development, use WEB_PORT=5173 # - The DATABASE_URL for Docker should use 'postgres' as hostname (handled automatically in docker-compose.yml) # - For Ollama in Docker, use OLLAMA_BASE_URL=http://host.docker.internal:11434 +# - For LM Studio in Docker, use LM_STUDIO_BASE_URL=http://host.docker.internal:1234/v1 # - VITE_API_URL: Optional, defaults to relative path '/api/v1' which works for both dev and production # Set to absolute URL (e.g., http://localhost:3000/api/v1) only if needed for specific deployment scenarios diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 8c0e49f..49302ce 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -23,6 +23,7 @@ import { createTagsRouter, createSearchRouter, createOllamaRouter, + createLMStudioRouter, createConversationsRouter, createTranscribeRouter, createTokenUsageRouter @@ -85,6 +86,7 @@ app.use('/api/v1/organize', createOrganizeRouter(db, settingsRepo, tokenTracking app.use('/api/v1/today-sheet', createTodaySheetRouter(db, settingsRepo, tokenTrackingService)); app.use('/api/v1/weekly-review', createWeeklyReviewRouter(db, settingsRepo, templatesRepo, tokenTrackingService)); app.use('/api/v1/ollama', createOllamaRouter(settingsRepo)); +app.use('/api/v1/lmstudio', createLMStudioRouter(settingsRepo)); app.use('/api/v1/conversations', createConversationsRouter(db, conversationsRepo, settingsRepo, tokenTrackingService)); app.use('/api/v1/transcribe', createTranscribeRouter(db, settingsRepo, notesRepo, tokenTrackingService)); app.use('/api/v1/token-usage', createTokenUsageRouter(tokenTrackingService)); diff --git a/apps/api/src/routes/index.ts b/apps/api/src/routes/index.ts index 03cabab..2584a15 100644 --- a/apps/api/src/routes/index.ts +++ b/apps/api/src/routes/index.ts @@ -6,6 +6,7 @@ export * from './settings.js'; export * from './tags.js'; export * from './search.js'; export * from './ollama.js'; +export * from './lmstudio.js'; export * from './conversations.js'; export * from './transcribe.js'; export * from './token-usage.js'; diff --git a/apps/api/src/routes/lmstudio.ts b/apps/api/src/routes/lmstudio.ts new file mode 100644 index 0000000..ce3b44b --- /dev/null +++ b/apps/api/src/routes/lmstudio.ts @@ -0,0 +1,68 @@ +import { Router, type Router as ExpressRouter } from 'express'; +import OpenAI from 'openai'; +import { SettingsRepository } from 'database'; +import { asyncHandler } from '../utils/async-handler.js'; + +const DEFAULT_BASE_URL = 'http://localhost:1234/v1'; +const PLACEHOLDER_API_KEY = 'lm-studio'; + +export interface LMStudioModel { + id: string; + object: string; + created?: number; + owned_by?: string; +} + +export function createLMStudioRouter(settingsRepo: SettingsRepository): ExpressRouter { + const router = Router(); + + // GET /api/v1/lmstudio/models - List models loaded in LM Studio + router.get( + '/models', + asyncHandler(async (req, res) => { + const userId = 'test-user-1'; // TODO: Get from auth context + + const settings = await settingsRepo.getOrCreate(userId); + const baseURL = settings.lmstudioBaseUrl || DEFAULT_BASE_URL; + + try { + const client = new OpenAI({ apiKey: PLACEHOLDER_API_KEY, baseURL }); + const response = await client.models.list(); + + const models: LMStudioModel[] = response.data.map((m) => ({ + id: m.id, + object: m.object, + created: m.created, + owned_by: m.owned_by, + })); + + res.json({ models }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to connect to LM Studio'; + res.status(503).json({ models: [], error: message }); + } + }), + ); + + // GET /api/v1/lmstudio/health - Check LM Studio connectivity + router.get( + '/health', + asyncHandler(async (req, res) => { + const userId = 'test-user-1'; // TODO: Get from auth context + + const settings = await settingsRepo.getOrCreate(userId); + const baseURL = settings.lmstudioBaseUrl || DEFAULT_BASE_URL; + + try { + const client = new OpenAI({ apiKey: PLACEHOLDER_API_KEY, baseURL }); + await client.models.list(); + res.json({ connected: true, baseURL }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to connect to LM Studio'; + res.json({ connected: false, baseURL, error: message }); + } + }), + ); + + return router; +} diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 5a9614b..99a2054 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -151,6 +151,24 @@ export const ollamaAPI = { listModels: () => fetchAPI('/ollama/models'), }; +// LM Studio +export interface LMStudioModel { + id: string; + object: string; + created?: number; + owned_by?: string; +} + +export interface LMStudioModelsResponse { + models: LMStudioModel[]; + error?: string; +} + +export const lmstudioAPI = { + listModels: () => fetchAPI('/lmstudio/models'), + health: () => fetchAPI<{ connected: boolean; baseURL: string; error?: string }>('/lmstudio/health'), +}; + // Conversations export interface Conversation { id: string; @@ -193,10 +211,11 @@ export const conversationsAPI = { export interface Settings { id: string; userId: string; - llmProvider: 'openai' | 'anthropic' | 'ollama'; + llmProvider: 'openai' | 'anthropic' | 'ollama' | 'lmstudio'; llmModel: string | null; llmTemperature: number; ollamaBaseUrl: string; + lmstudioBaseUrl: string; // Local Whisper whisperEnabled: boolean; diff --git a/apps/web/src/pages/SettingsPage.tsx b/apps/web/src/pages/SettingsPage.tsx index 48319be..b892bce 100644 --- a/apps/web/src/pages/SettingsPage.tsx +++ b/apps/web/src/pages/SettingsPage.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; -import { settingsAPI, ollamaAPI, tokenUsageAPI, type Settings, type OllamaModel, type UsageSummary } from '../api/client'; +import { settingsAPI, ollamaAPI, lmstudioAPI, tokenUsageAPI, type Settings, type OllamaModel, type LMStudioModel, type UsageSummary } from '../api/client'; import { Cog, ChevronDown, ChevronUp, RefreshCw } from 'lucide-react'; import { getServerUrl, setApiUrl, testConnection } from '../api/config'; @@ -13,6 +13,7 @@ const isElectron = typeof window !== 'undefined' && window.electronAPI?.isElectr // Default values for settings fields const DEFAULT_OLLAMA_URL = 'http://localhost:11434'; +const DEFAULT_LM_STUDIO_URL = 'http://localhost:1234/v1'; const DEFAULT_SCHEDULE = '0 17 * * *'; const PROVIDER_MODELS: Record = { @@ -43,6 +44,10 @@ const PROVIDER_MODELS: Record(null); + // LM Studio models state + const [lmstudioModels, setLMStudioModels] = useState([]); + const [isLoadingLMStudioModels, setIsLoadingLMStudioModels] = useState(false); + const [lmstudioModelsError, setLMStudioModelsError] = useState(null); + const fetchOllamaModels = useCallback(async (autoSelectFirst = false) => { setIsLoadingOllamaModels(true); setOllamaModelsError(null); @@ -96,6 +108,27 @@ export default function SettingsPage() { } }, [settings?.llmModel]); + const fetchLMStudioModels = useCallback(async (autoSelectFirst = false) => { + setIsLoadingLMStudioModels(true); + setLMStudioModelsError(null); + try { + const response = await lmstudioAPI.listModels(); + setLMStudioModels(response.models); + if (response.error) { + setLMStudioModelsError(response.error); + } + // Auto-select first model if none selected and models available + if (autoSelectFirst && response.models.length > 0 && !settings?.llmModel) { + handleUpdate({ llmModel: response.models[0].id }); + } + } catch (err) { + setLMStudioModelsError('Failed to fetch LM Studio models'); + console.error('Failed to fetch LM Studio models:', err); + } finally { + setIsLoadingLMStudioModels(false); + } + }, [settings?.llmModel]); + // Fetch Ollama models when provider is ollama useEffect(() => { if (settings?.llmProvider === 'ollama') { @@ -103,6 +136,13 @@ export default function SettingsPage() { } }, [settings?.llmProvider, fetchOllamaModels]); + // Fetch LM Studio models when provider is lmstudio + useEffect(() => { + if (settings?.llmProvider === 'lmstudio') { + fetchLMStudioModels(true); + } + }, [settings?.llmProvider, fetchLMStudioModels]); + const handleTestConnection = async () => { setIsTesting(true); setConnectionStatus('idle'); @@ -128,6 +168,7 @@ export default function SettingsPage() { setSettings(data); // Initialize local state from loaded settings setLocalOllamaUrl(data.ollamaBaseUrl ?? DEFAULT_OLLAMA_URL); + setLocalLMStudioUrl(data.lmstudioBaseUrl ?? DEFAULT_LM_STUDIO_URL); setLocalWhisperUrl(data.whisperUrl ?? 'http://127.0.0.1:3005'); setLocalTodaySheetTime(data.todaySheetTime ?? '08:00'); setLocalOrganizeTime(data.organizeScheduleTime ?? '17:00'); @@ -150,6 +191,9 @@ export default function SettingsPage() { if (!isEditingOllamaUrl) { setLocalOllamaUrl(settings.ollamaBaseUrl ?? DEFAULT_OLLAMA_URL); } + if (!isEditingLMStudioUrl) { + setLocalLMStudioUrl(settings.lmstudioBaseUrl ?? DEFAULT_LM_STUDIO_URL); + } if (!isEditingWhisperUrl) { setLocalWhisperUrl(settings.whisperUrl ?? 'http://127.0.0.1:3005'); } @@ -160,7 +204,7 @@ export default function SettingsPage() { setLocalOrganizeTime(settings.organizeScheduleTime ?? '17:00'); } } - }, [settings, isEditingOllamaUrl, isEditingWhisperUrl, isEditingTodaySheetTime, isEditingOrganizeTime]); + }, [settings, isEditingOllamaUrl, isEditingLMStudioUrl, isEditingWhisperUrl, isEditingTodaySheetTime, isEditingOrganizeTime]); const handleUpdate = async (updates: Partial) => { if (!settings) return; @@ -312,6 +356,41 @@ export default function SettingsPage() {

No models found. Pull a model with: ollama pull llama3.1

)} + ) : settings.llmProvider === 'lmstudio' ? ( +
+
+ + +
+ {isLoadingLMStudioModels && ( +

Loading models from LM Studio...

+ )} + {lmstudioModelsError && ( +

{lmstudioModelsError} — is LM Studio running?

+ )} + {!isLoadingLMStudioModels && !lmstudioModelsError && lmstudioModels.length === 0 && ( +

No models found. Load a model in LM Studio first.

+ )} +
) : ( setLocalLMStudioUrl(e.target.value)} + onFocus={() => setIsEditingLMStudioUrl(true)} + onBlur={async () => { + setIsEditingLMStudioUrl(false); + const currentValue = settings.lmstudioBaseUrl ?? DEFAULT_LM_STUDIO_URL; + if (localLMStudioUrl !== currentValue) { + await handleUpdate({ lmstudioBaseUrl: localLMStudioUrl }); + fetchLMStudioModels(); + } + }} + disabled={false} + placeholder={DEFAULT_LM_STUDIO_URL} + className="input-accent w-full max-w-md" + /> +

+ Enable the local server in LM Studio → Developer → Local Server +

+ + )} + {/* Local Whisper */}
diff --git a/packages/database/migrations/0019_add_lmstudio_provider.sql b/packages/database/migrations/0019_add_lmstudio_provider.sql new file mode 100644 index 0000000..034f2dd --- /dev/null +++ b/packages/database/migrations/0019_add_lmstudio_provider.sql @@ -0,0 +1,3 @@ +ALTER TYPE "llm_provider" ADD VALUE 'lmstudio'; +--> statement-breakpoint +ALTER TABLE "settings" ADD COLUMN "lmstudio_base_url" text NOT NULL DEFAULT 'http://localhost:1234/v1'; diff --git a/packages/database/migrations/meta/_journal.json b/packages/database/migrations/meta/_journal.json index 6887968..a21623c 100644 --- a/packages/database/migrations/meta/_journal.json +++ b/packages/database/migrations/meta/_journal.json @@ -134,6 +134,13 @@ "when": 1772047833109, "tag": "0018_melted_golden_guardian", "breakpoints": true + }, + { + "idx": 19, + "version": "5", + "when": 1773000000000, + "tag": "0019_add_lmstudio_provider", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/database/src/schema/settings.ts b/packages/database/src/schema/settings.ts index 21a1b31..811c25e 100644 --- a/packages/database/src/schema/settings.ts +++ b/packages/database/src/schema/settings.ts @@ -1,6 +1,6 @@ import { pgTable, uuid, text, timestamp, real, boolean, index, pgEnum } from 'drizzle-orm/pg-core'; -export const llmProviderEnum = pgEnum('llm_provider', ['openai', 'anthropic', 'ollama']); +export const llmProviderEnum = pgEnum('llm_provider', ['openai', 'anthropic', 'ollama', 'lmstudio']); export const scheduleFrequencyEnum = pgEnum('schedule_frequency', ['daily', 'weekly']); export const settings = pgTable( @@ -12,6 +12,7 @@ export const settings = pgTable( llmModel: text('llm_model'), llmTemperature: real('llm_temperature').notNull().default(0.7), ollamaBaseUrl: text('ollama_base_url').notNull().default('http://localhost:11434'), + lmstudioBaseUrl: text('lmstudio_base_url').notNull().default('http://localhost:1234/v1'), // Today Sheet scheduling todaySheetScheduleEnabled: boolean('today_sheet_schedule_enabled').notNull().default(false), diff --git a/packages/llm/src/index.ts b/packages/llm/src/index.ts index 7c57bd1..7d3b71e 100644 --- a/packages/llm/src/index.ts +++ b/packages/llm/src/index.ts @@ -3,5 +3,6 @@ export * from './provider-factory.js'; export * from './providers/openai-provider.js'; export * from './providers/anthropic-provider.js'; export * from './providers/ollama-provider.js'; +export * from './providers/lmstudio-provider.js'; export * from './chat-tools/chat-tools.js'; export * from './utils/audio-utils.js'; diff --git a/packages/llm/src/provider-factory.ts b/packages/llm/src/provider-factory.ts index abdf6a1..c3da4ca 100644 --- a/packages/llm/src/provider-factory.ts +++ b/packages/llm/src/provider-factory.ts @@ -2,8 +2,9 @@ import type { LLMProvider, SettingsConfig } from './types.js'; import { OpenAIProvider } from './providers/openai-provider.js'; import { AnthropicProvider } from './providers/anthropic-provider.js'; import { OllamaProvider } from './providers/ollama-provider.js'; +import { LMStudioProvider } from './providers/lmstudio-provider.js'; -export type ProviderType = 'openai' | 'anthropic' | 'ollama'; +export type ProviderType = 'openai' | 'anthropic' | 'ollama' | 'lmstudio'; export class ProviderFactory { static createProvider( @@ -37,6 +38,13 @@ export class ProviderFactory { temperature: config.temperature, }); + case 'lmstudio': + return new LMStudioProvider({ + baseURL: config.baseURL || process.env.LM_STUDIO_BASE_URL, + model: config.model, + temperature: config.temperature, + }); + default: throw new Error(`Unknown LLM provider: ${type}`); } @@ -45,9 +53,9 @@ export class ProviderFactory { static createFromEnv(): LLMProvider { const providerType = (process.env.LLM_PROVIDER || 'openai').toLowerCase() as ProviderType; - if (!['openai', 'anthropic', 'ollama'].includes(providerType)) { + if (!['openai', 'anthropic', 'ollama', 'lmstudio'].includes(providerType)) { throw new Error( - `Invalid LLM_PROVIDER: ${providerType}. Must be one of: openai, anthropic, ollama` + `Invalid LLM_PROVIDER: ${providerType}. Must be one of: openai, anthropic, ollama, lmstudio` ); } @@ -57,10 +65,14 @@ export class ProviderFactory { static createFromSettings(settings: SettingsConfig): LLMProvider { const providerType = settings.llmProvider; + let baseURL: string | undefined; + if (providerType === 'ollama') baseURL = settings.ollamaBaseUrl; + if (providerType === 'lmstudio') baseURL = settings.lmstudioBaseUrl; + return this.createProvider(providerType, { model: settings.llmModel || undefined, temperature: settings.llmTemperature, - baseURL: providerType === 'ollama' ? settings.ollamaBaseUrl : undefined, + baseURL, }); } } diff --git a/packages/llm/src/providers/lmstudio-provider.ts b/packages/llm/src/providers/lmstudio-provider.ts new file mode 100644 index 0000000..f99e0f1 --- /dev/null +++ b/packages/llm/src/providers/lmstudio-provider.ts @@ -0,0 +1,402 @@ +import OpenAI from 'openai'; +import type { Capture, Template, Tag } from 'types'; +import { BaseLLMProvider } from '../base-provider.js'; +import type { + ChatMessage, + LLMProvider, + OrganizedOutput, + ProviderConfig, + StreamCallbacks, + ToolCall, + ToolDefinition, + TodaySheetInput, + TodaySheetOutput, + TranscribeOptions, + TranscriptionResult, + WeeklyReviewInput, + WeeklyReviewOutput, + TemplateSuggestionsOutput, +} from '../types.js'; +import { + organizedOutputSchema, + todaySheetOutputSchema, + weeklyReviewOutputSchema, + templateSuggestionsOutputSchema, + extractTasksOutputSchema, + refineNoteOutputSchema, +} from '../validation.js'; + +const DEFAULT_BASE_URL = 'http://localhost:1234/v1'; +// LM Studio requires a non-empty API key field but does not validate it +const PLACEHOLDER_API_KEY = 'lm-studio'; + +export class LMStudioProvider extends BaseLLMProvider implements LLMProvider { + private client: OpenAI; + private model: string; + private temperature: number; + + constructor(config: ProviderConfig) { + super(); + + const baseURL = config.baseURL || DEFAULT_BASE_URL; + this.client = new OpenAI({ apiKey: PLACEHOLDER_API_KEY, baseURL }); + + // Empty string means "use whatever model is currently loaded in LM Studio" + this.model = config.model || ''; + + let temp = config.temperature ?? 0.7; + temp = Math.max(0.3, Math.min(1, temp)); + this.temperature = temp; + } + + private storeUsage(response: { usage?: { prompt_tokens?: number; completion_tokens?: number } | null }) { + if (response.usage) { + this.lastUsage = { + inputTokens: response.usage.prompt_tokens ?? null, + outputTokens: response.usage.completion_tokens ?? null, + }; + } else { + this.lastUsage = null; + } + } + + /** + * LM Studio supports json_object mode but not OpenAI's json_schema Structured Outputs. + * We always use json_object and rely on our Zod-based parseResponse for validation. + */ + private get jsonResponseFormat(): OpenAI.ResponseFormatJSONObject { + return { type: 'json_object' }; + } + + async organize( + captures: Capture[], + template: Template, + tags?: Tag[], + includeDescriptions?: boolean, + contentLockEnabled?: boolean, + ): Promise { + const systemPrompt = this.buildSystemPrompt(); + const userPrompt = this.buildOrganizePrompt( + captures, + template, + tags, + includeDescriptions ?? false, + contentLockEnabled ?? false, + ); + + const response = await this.client.chat.completions.create({ + model: this.model, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userPrompt }, + ], + temperature: this.temperature, + response_format: this.jsonResponseFormat, + }); + + this.storeUsage(response); + const content = response.choices[0]?.message?.content; + if (!content) throw new Error('Empty response from LM Studio'); + + return this.parseResponse(content, organizedOutputSchema); + } + + async extractTasks(text: string): Promise<{ content: string; dueDate?: string }[]> { + const prompt = this.buildTaskExtractionPrompt(text); + + const response = await this.client.chat.completions.create({ + model: this.model, + messages: [ + { role: 'system', content: this.buildSystemPrompt() }, + { role: 'user', content: prompt }, + ], + temperature: this.temperature, + response_format: this.jsonResponseFormat, + }); + + this.storeUsage(response); + const content = response.choices[0]?.message?.content; + if (!content) throw new Error('Empty response from LM Studio'); + + const result = this.parseResponse<{ todos: { content: string; dueDate?: string }[] }>( + content, + extractTasksOutputSchema, + ); + return result.todos; + } + + async generateTodaySheet(input: TodaySheetInput): Promise { + const systemPrompt = this.buildSystemPrompt(); + const userPrompt = this.buildTodaySheetPrompt(input); + + const response = await this.client.chat.completions.create({ + model: this.model, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userPrompt }, + ], + temperature: this.temperature, + response_format: this.jsonResponseFormat, + }); + + this.storeUsage(response); + const content = response.choices[0]?.message?.content; + if (!content) throw new Error('Empty response from LM Studio'); + + return this.parseResponse(content, todaySheetOutputSchema); + } + + async generateTitle(messages: ChatMessage[]): Promise { + const conversationText = messages + .filter((m) => m.role === 'user' || m.role === 'assistant') + .map((m) => `${m.role}: ${m.content ?? ''}`) + .join('\n'); + + const response = await this.client.chat.completions.create({ + model: this.model, + max_tokens: 30, + temperature: 0.3, + messages: [ + { role: 'system', content: this.buildChatTitleSystemPrompt() }, + { role: 'user', content: conversationText }, + ], + }); + + this.storeUsage(response); + const content = response.choices[0]?.message?.content; + if (!content) throw new Error('Empty response from LM Studio'); + + return content.trim(); + } + + async streamChat( + messages: ChatMessage[], + callbacks: StreamCallbacks, + tools?: ToolDefinition[], + ): Promise { + // Map our ChatMessage to OpenAI format + const openaiMessages = messages.map((m) => { + if (m.role === 'tool') { + if (!m.toolCallId) throw new Error('toolCallId is required for tool messages'); + return { role: 'tool' as const, content: m.content ?? '', tool_call_id: m.toolCallId }; + } + if (m.role === 'assistant' && m.toolCalls?.length) { + return { + role: 'assistant' as const, + content: m.content ?? null, + tool_calls: m.toolCalls.map((tc) => ({ + id: tc.id, + type: 'function' as const, + function: { name: tc.name, arguments: JSON.stringify(tc.arguments) }, + })), + }; + } + return { + role: m.role as 'user' | 'assistant' | 'system', + content: m.content ?? '', + }; + }); + + const openaiTools = tools?.map((t) => ({ + type: 'function' as const, + function: { name: t.name, description: t.description, parameters: t.input_schema }, + })); + + // Try with tools first; fall back to plain streaming if the model doesn't support them + if (openaiTools?.length) { + try { + await this._streamWithTools(openaiMessages, callbacks, openaiTools); + return; + } catch (error: unknown) { + const msg = error instanceof Error ? error.message : String(error); + const isToolsUnsupported = + msg.toLowerCase().includes('does not support tools') || + msg.toLowerCase().includes('tool_choice') || + (msg.toLowerCase().includes('tool') && msg.toLowerCase().includes('not supported')); + + if (!isToolsUnsupported) { + callbacks.onError?.(error instanceof Error ? error : new Error(msg)); + throw error; + } + // Fall through to plain streaming + } + } + + await this._streamPlain(openaiMessages, callbacks); + } + + private async _streamWithTools( + messages: OpenAI.Chat.ChatCompletionMessageParam[], + callbacks: StreamCallbacks, + tools: OpenAI.Chat.ChatCompletionTool[], + ): Promise { + let fullResponse = ''; + const toolCallsInProgress: Map = + new Map(); + + const response = await this.client.chat.completions.create({ + model: this.model, + messages, + stream: true, + temperature: this.temperature, + tools, + }); + + for await (const chunk of response) { + const delta = chunk.choices[0]?.delta; + const finishReason = chunk.choices[0]?.finish_reason; + + if (delta?.content) { + callbacks.onToken(delta.content); + fullResponse += delta.content; + } + + if (delta?.tool_calls) { + for (const toolCallDelta of delta.tool_calls) { + const index = toolCallDelta.index; + if (!toolCallsInProgress.has(index)) { + toolCallsInProgress.set(index, { + id: toolCallDelta.id ?? '', + name: toolCallDelta.function?.name ?? '', + arguments: '', + }); + } + const tc = toolCallsInProgress.get(index)!; + if (toolCallDelta.id) tc.id = toolCallDelta.id; + if (toolCallDelta.function?.name) tc.name = toolCallDelta.function.name; + if (toolCallDelta.function?.arguments) tc.arguments += toolCallDelta.function.arguments; + } + } + + if (finishReason === 'tool_calls') { + for (const [, tc] of toolCallsInProgress) { + let parsedArgs: Record = {}; + try { + parsedArgs = JSON.parse(tc.arguments || '{}'); + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + callbacks.onError?.( + new Error( + `Failed to parse tool call arguments for "${tc.name}": ${error.message}. Raw: ${tc.arguments}`, + ), + ); + continue; + } + const toolCall: ToolCall = { id: tc.id, name: tc.name, arguments: parsedArgs }; + callbacks.onToolCall?.(toolCall); + } + callbacks.onComplete(fullResponse); + } else if (finishReason === 'stop') { + callbacks.onComplete(fullResponse); + } + } + } + + private async _streamPlain( + messages: OpenAI.Chat.ChatCompletionMessageParam[], + callbacks: StreamCallbacks, + ): Promise { + let fullResponse = ''; + + const response = await this.client.chat.completions.create({ + model: this.model, + messages, + stream: true, + temperature: this.temperature, + }); + + for await (const chunk of response) { + const delta = chunk.choices[0]?.delta; + const finishReason = chunk.choices[0]?.finish_reason; + + if (delta?.content) { + callbacks.onToken(delta.content); + fullResponse += delta.content; + } + + if (finishReason === 'stop') { + callbacks.onComplete(fullResponse); + } + } + } + + async refineNote( + title: string, + content: string, + prompt: string, + ): Promise<{ title: string; content: string }> { + const systemPrompt = this.buildRefineNoteSystemPrompt(); + const userPrompt = this.buildRefineNotePrompt(title, content, prompt); + + const response = await this.client.chat.completions.create({ + model: this.model, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userPrompt }, + ], + temperature: this.temperature, + response_format: this.jsonResponseFormat, + }); + + this.storeUsage(response); + const responseContent = response.choices[0]?.message?.content; + if (!responseContent) throw new Error('Empty response from LM Studio'); + + return this.parseResponse<{ title: string; content: string }>( + responseContent, + refineNoteOutputSchema, + ); + } + + async transcribe(_audioBuffer: Buffer, _options?: TranscribeOptions): Promise { + throw new Error( + 'Transcription is not supported by the LM Studio provider. Enable local whisper in settings.', + ); + } + + async generateWeeklyReview(input: WeeklyReviewInput): Promise { + const userPrompt = this.buildWeeklyReviewPrompt(input); + + const response = await this.client.chat.completions.create({ + model: this.model, + messages: [ + { role: 'system', content: 'You are a productivity coach helping users reflect on their week.' }, + { role: 'user', content: userPrompt }, + ], + temperature: this.temperature, + response_format: this.jsonResponseFormat, + }); + + this.storeUsage(response); + const content = response.choices[0]?.message?.content; + if (!content) throw new Error('Empty response from LM Studio'); + + return this.parseResponse(content, weeklyReviewOutputSchema); + } + + async generateTemplateSuggestions( + template: Template, + weeklyReview?: WeeklyReviewOutput, + ): Promise { + const userPrompt = this.buildTemplateSuggestionsPrompt(template, weeklyReview); + + const response = await this.client.chat.completions.create({ + model: this.model, + messages: [ + { + role: 'system', + content: 'You are a productivity coach helping users improve their organization templates.', + }, + { role: 'user', content: userPrompt }, + ], + temperature: this.temperature, + response_format: this.jsonResponseFormat, + }); + + this.storeUsage(response); + const content = response.choices[0]?.message?.content; + if (!content) throw new Error('Empty response from LM Studio'); + + return this.parseResponse(content, templateSuggestionsOutputSchema); + } +} diff --git a/packages/llm/src/types.ts b/packages/llm/src/types.ts index b55edda..f0959a9 100644 --- a/packages/llm/src/types.ts +++ b/packages/llm/src/types.ts @@ -213,8 +213,9 @@ export interface ProviderConfig { // Settings-based configuration (from database) export interface SettingsConfig { - llmProvider: 'openai' | 'anthropic' | 'ollama'; + llmProvider: 'openai' | 'anthropic' | 'ollama' | 'lmstudio'; llmModel: string | null; llmTemperature: number; ollamaBaseUrl: string; + lmstudioBaseUrl: string; } From 43ce64e28fe4fa82ad99e128bf721650f0826a95 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Mar 2026 23:23:54 +0000 Subject: [PATCH 2/4] Fix lmstudio route: use fetch instead of openai SDK apps/api doesn't have openai as a direct dependency. Replace OpenAI client with a direct fetch to the LM Studio /v1/models endpoint (OpenAI-compatible), fixing the TS2307 and TS7006 build errors. https://claude.ai/code/session_014vPCcYQRHJpXauLWVzqVvG --- apps/api/src/routes/lmstudio.ts | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/apps/api/src/routes/lmstudio.ts b/apps/api/src/routes/lmstudio.ts index ce3b44b..c70e2b0 100644 --- a/apps/api/src/routes/lmstudio.ts +++ b/apps/api/src/routes/lmstudio.ts @@ -1,10 +1,8 @@ import { Router, type Router as ExpressRouter } from 'express'; -import OpenAI from 'openai'; import { SettingsRepository } from 'database'; import { asyncHandler } from '../utils/async-handler.js'; const DEFAULT_BASE_URL = 'http://localhost:1234/v1'; -const PLACEHOLDER_API_KEY = 'lm-studio'; export interface LMStudioModel { id: string; @@ -26,10 +24,17 @@ export function createLMStudioRouter(settingsRepo: SettingsRepository): ExpressR const baseURL = settings.lmstudioBaseUrl || DEFAULT_BASE_URL; try { - const client = new OpenAI({ apiKey: PLACEHOLDER_API_KEY, baseURL }); - const response = await client.models.list(); + const response = await fetch(`${baseURL}/models`, { + headers: { Authorization: 'Bearer lm-studio' }, + }); - const models: LMStudioModel[] = response.data.map((m) => ({ + if (!response.ok) { + throw new Error(`LM Studio returned ${response.status}: ${response.statusText}`); + } + + const data = await response.json() as { data: LMStudioModel[] }; + + const models: LMStudioModel[] = (data.data ?? []).map((m: LMStudioModel) => ({ id: m.id, object: m.object, created: m.created, @@ -54,8 +59,14 @@ export function createLMStudioRouter(settingsRepo: SettingsRepository): ExpressR const baseURL = settings.lmstudioBaseUrl || DEFAULT_BASE_URL; try { - const client = new OpenAI({ apiKey: PLACEHOLDER_API_KEY, baseURL }); - await client.models.list(); + const response = await fetch(`${baseURL}/models`, { + headers: { Authorization: 'Bearer lm-studio' }, + }); + + if (!response.ok) { + throw new Error(`LM Studio returned ${response.status}: ${response.statusText}`); + } + res.json({ connected: true, baseURL }); } catch (error) { const message = error instanceof Error ? error.message : 'Failed to connect to LM Studio'; From eb0aff55e93c4ab4c0bdfef5296c905029dbe148 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Mar 2026 00:02:19 +0000 Subject: [PATCH 3/4] Address PR review feedback for LM Studio support - Base URL: users now enter host:port only (e.g. http://localhost:1234); the /v1 path is appended automatically by the provider and API route. Added a toBaseURL() helper that also handles the case where a user has already stored a /v1 URL (backwards compatible). - Models: added display_name to LMStudioModel type (API route, web client) and use it in the SettingsPage dropdown for user-facing labels. - Updated migration default and schema default to http://localhost:1234. - Updated .env.example accordingly. https://claude.ai/code/session_014vPCcYQRHJpXauLWVzqVvG --- .env.example | 4 ++-- apps/api/src/routes/lmstudio.ts | 14 +++++++++++--- apps/web/src/api/client.ts | 1 + apps/web/src/pages/SettingsPage.tsx | 4 ++-- .../migrations/0019_add_lmstudio_provider.sql | 2 +- packages/database/src/schema/settings.ts | 2 +- packages/llm/src/providers/lmstudio-provider.ts | 6 ++++-- 7 files changed, 22 insertions(+), 11 deletions(-) diff --git a/.env.example b/.env.example index 6974117..4f7b768 100644 --- a/.env.example +++ b/.env.example @@ -9,7 +9,7 @@ POSTGRES_PORT=5432 OPENAI_API_KEY=sk-... ANTHROPIC_API_KEY=sk-ant-... OLLAMA_BASE_URL=http://localhost:11434 -LM_STUDIO_BASE_URL=http://localhost:1234/v1 +LM_STUDIO_BASE_URL=http://localhost:1234 # Server Configuration API_PORT=3000 # External port mapping for API (Docker only) @@ -25,6 +25,6 @@ TZ=America/Chicago # - For local development, use WEB_PORT=5173 # - The DATABASE_URL for Docker should use 'postgres' as hostname (handled automatically in docker-compose.yml) # - For Ollama in Docker, use OLLAMA_BASE_URL=http://host.docker.internal:11434 -# - For LM Studio in Docker, use LM_STUDIO_BASE_URL=http://host.docker.internal:1234/v1 +# - For LM Studio in Docker, use LM_STUDIO_BASE_URL=http://host.docker.internal:1234 # - VITE_API_URL: Optional, defaults to relative path '/api/v1' which works for both dev and production # Set to absolute URL (e.g., http://localhost:3000/api/v1) only if needed for specific deployment scenarios diff --git a/apps/api/src/routes/lmstudio.ts b/apps/api/src/routes/lmstudio.ts index c70e2b0..1639cb0 100644 --- a/apps/api/src/routes/lmstudio.ts +++ b/apps/api/src/routes/lmstudio.ts @@ -2,15 +2,22 @@ import { Router, type Router as ExpressRouter } from 'express'; import { SettingsRepository } from 'database'; import { asyncHandler } from '../utils/async-handler.js'; -const DEFAULT_BASE_URL = 'http://localhost:1234/v1'; +const DEFAULT_HOST = 'http://localhost:1234'; export interface LMStudioModel { id: string; object: string; + display_name?: string; created?: number; owned_by?: string; } +/** Normalise a stored host value (host:port) to a full /v1 base URL */ +function toBaseURL(host: string): string { + const trimmed = host.replace(/\/+$/, ''); + return trimmed.endsWith('/v1') ? trimmed : `${trimmed}/v1`; +} + export function createLMStudioRouter(settingsRepo: SettingsRepository): ExpressRouter { const router = Router(); @@ -21,7 +28,7 @@ export function createLMStudioRouter(settingsRepo: SettingsRepository): ExpressR const userId = 'test-user-1'; // TODO: Get from auth context const settings = await settingsRepo.getOrCreate(userId); - const baseURL = settings.lmstudioBaseUrl || DEFAULT_BASE_URL; + const baseURL = toBaseURL(settings.lmstudioBaseUrl || DEFAULT_HOST); try { const response = await fetch(`${baseURL}/models`, { @@ -37,6 +44,7 @@ export function createLMStudioRouter(settingsRepo: SettingsRepository): ExpressR const models: LMStudioModel[] = (data.data ?? []).map((m: LMStudioModel) => ({ id: m.id, object: m.object, + display_name: m.display_name, created: m.created, owned_by: m.owned_by, })); @@ -56,7 +64,7 @@ export function createLMStudioRouter(settingsRepo: SettingsRepository): ExpressR const userId = 'test-user-1'; // TODO: Get from auth context const settings = await settingsRepo.getOrCreate(userId); - const baseURL = settings.lmstudioBaseUrl || DEFAULT_BASE_URL; + const baseURL = toBaseURL(settings.lmstudioBaseUrl || DEFAULT_HOST); try { const response = await fetch(`${baseURL}/models`, { diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 99a2054..e96e51a 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -155,6 +155,7 @@ export const ollamaAPI = { export interface LMStudioModel { id: string; object: string; + display_name?: string; created?: number; owned_by?: string; } diff --git a/apps/web/src/pages/SettingsPage.tsx b/apps/web/src/pages/SettingsPage.tsx index b892bce..7b8232b 100644 --- a/apps/web/src/pages/SettingsPage.tsx +++ b/apps/web/src/pages/SettingsPage.tsx @@ -13,7 +13,7 @@ const isElectron = typeof window !== 'undefined' && window.electronAPI?.isElectr // Default values for settings fields const DEFAULT_OLLAMA_URL = 'http://localhost:11434'; -const DEFAULT_LM_STUDIO_URL = 'http://localhost:1234/v1'; +const DEFAULT_LM_STUDIO_URL = 'http://localhost:1234'; const DEFAULT_SCHEDULE = '0 17 * * *'; const PROVIDER_MODELS: Record = { @@ -368,7 +368,7 @@ export default function SettingsPage() { {lmstudioModels.map((model) => ( ))} diff --git a/packages/database/migrations/0019_add_lmstudio_provider.sql b/packages/database/migrations/0019_add_lmstudio_provider.sql index 034f2dd..5b00ae9 100644 --- a/packages/database/migrations/0019_add_lmstudio_provider.sql +++ b/packages/database/migrations/0019_add_lmstudio_provider.sql @@ -1,3 +1,3 @@ ALTER TYPE "llm_provider" ADD VALUE 'lmstudio'; --> statement-breakpoint -ALTER TABLE "settings" ADD COLUMN "lmstudio_base_url" text NOT NULL DEFAULT 'http://localhost:1234/v1'; +ALTER TABLE "settings" ADD COLUMN "lmstudio_base_url" text NOT NULL DEFAULT 'http://localhost:1234'; diff --git a/packages/database/src/schema/settings.ts b/packages/database/src/schema/settings.ts index 811c25e..5202f88 100644 --- a/packages/database/src/schema/settings.ts +++ b/packages/database/src/schema/settings.ts @@ -12,7 +12,7 @@ export const settings = pgTable( llmModel: text('llm_model'), llmTemperature: real('llm_temperature').notNull().default(0.7), ollamaBaseUrl: text('ollama_base_url').notNull().default('http://localhost:11434'), - lmstudioBaseUrl: text('lmstudio_base_url').notNull().default('http://localhost:1234/v1'), + lmstudioBaseUrl: text('lmstudio_base_url').notNull().default('http://localhost:1234'), // Today Sheet scheduling todaySheetScheduleEnabled: boolean('today_sheet_schedule_enabled').notNull().default(false), diff --git a/packages/llm/src/providers/lmstudio-provider.ts b/packages/llm/src/providers/lmstudio-provider.ts index f99e0f1..6f36927 100644 --- a/packages/llm/src/providers/lmstudio-provider.ts +++ b/packages/llm/src/providers/lmstudio-provider.ts @@ -26,7 +26,7 @@ import { refineNoteOutputSchema, } from '../validation.js'; -const DEFAULT_BASE_URL = 'http://localhost:1234/v1'; +const DEFAULT_HOST = 'http://localhost:1234'; // LM Studio requires a non-empty API key field but does not validate it const PLACEHOLDER_API_KEY = 'lm-studio'; @@ -38,7 +38,9 @@ export class LMStudioProvider extends BaseLLMProvider implements LLMProvider { constructor(config: ProviderConfig) { super(); - const baseURL = config.baseURL || DEFAULT_BASE_URL; + // Accept host:port (e.g. http://localhost:1234) and always append /v1 + const host = (config.baseURL || DEFAULT_HOST).replace(/\/+$/, ''); + const baseURL = host.endsWith('/v1') ? host : `${host}/v1`; this.client = new OpenAI({ apiKey: PLACEHOLDER_API_KEY, baseURL }); // Empty string means "use whatever model is currently loaded in LM Studio" From a122d0a6ad885935db120ebf8efcf28872c7a612 Mon Sep 17 00:00:00 2001 From: Brad Slayter Date: Fri, 13 Mar 2026 08:06:19 -0500 Subject: [PATCH 4/4] LM Studio fixes --- .../llm/src/providers/lmstudio-provider.ts | 29 ++++++++++++------- packages/types/src/validation.ts | 3 +- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/packages/llm/src/providers/lmstudio-provider.ts b/packages/llm/src/providers/lmstudio-provider.ts index 6f36927..faddfbf 100644 --- a/packages/llm/src/providers/lmstudio-provider.ts +++ b/packages/llm/src/providers/lmstudio-provider.ts @@ -1,4 +1,6 @@ import OpenAI from 'openai'; +import { zodToJsonSchema } from 'zod-to-json-schema'; +import type { ZodType } from 'zod'; import type { Capture, Template, Tag } from 'types'; import { BaseLLMProvider } from '../base-provider.js'; import type { @@ -63,11 +65,18 @@ export class LMStudioProvider extends BaseLLMProvider implements LLMProvider { } /** - * LM Studio supports json_object mode but not OpenAI's json_schema Structured Outputs. - * We always use json_object and rely on our Zod-based parseResponse for validation. + * Build a json_schema response_format from a Zod schema. + * LM Studio uses grammar-based sampling to guarantee the output matches the schema, + * which prevents reasoning tokens ( tags) from leaking into the response. */ - private get jsonResponseFormat(): OpenAI.ResponseFormatJSONObject { - return { type: 'json_object' }; + private structuredFormat(schema: ZodType, name: string): OpenAI.ResponseFormatJSONSchema { + const jsonSchema = zodToJsonSchema(schema, { $refStrategy: 'none', target: 'openApi3' }); + // Strip the top-level $schema field — LM Studio doesn't need it + const { $schema: _unused, ...cleanSchema } = jsonSchema as Record; + return { + type: 'json_schema', + json_schema: { name, strict: true, schema: cleanSchema }, + }; } async organize( @@ -93,7 +102,7 @@ export class LMStudioProvider extends BaseLLMProvider implements LLMProvider { { role: 'user', content: userPrompt }, ], temperature: this.temperature, - response_format: this.jsonResponseFormat, + response_format: this.structuredFormat(organizedOutputSchema, 'organized_output'), }); this.storeUsage(response); @@ -113,7 +122,7 @@ export class LMStudioProvider extends BaseLLMProvider implements LLMProvider { { role: 'user', content: prompt }, ], temperature: this.temperature, - response_format: this.jsonResponseFormat, + response_format: this.structuredFormat(extractTasksOutputSchema, 'extract_tasks_output'), }); this.storeUsage(response); @@ -138,7 +147,7 @@ export class LMStudioProvider extends BaseLLMProvider implements LLMProvider { { role: 'user', content: userPrompt }, ], temperature: this.temperature, - response_format: this.jsonResponseFormat, + response_format: this.structuredFormat(todaySheetOutputSchema, 'today_sheet_output'), }); this.storeUsage(response); @@ -337,7 +346,7 @@ export class LMStudioProvider extends BaseLLMProvider implements LLMProvider { { role: 'user', content: userPrompt }, ], temperature: this.temperature, - response_format: this.jsonResponseFormat, + response_format: this.structuredFormat(refineNoteOutputSchema, 'refine_note_output'), }); this.storeUsage(response); @@ -366,7 +375,7 @@ export class LMStudioProvider extends BaseLLMProvider implements LLMProvider { { role: 'user', content: userPrompt }, ], temperature: this.temperature, - response_format: this.jsonResponseFormat, + response_format: this.structuredFormat(weeklyReviewOutputSchema, 'weekly_review_output'), }); this.storeUsage(response); @@ -392,7 +401,7 @@ export class LMStudioProvider extends BaseLLMProvider implements LLMProvider { { role: 'user', content: userPrompt }, ], temperature: this.temperature, - response_format: this.jsonResponseFormat, + response_format: this.structuredFormat(templateSuggestionsOutputSchema, 'template_suggestions_output'), }); this.storeUsage(response); diff --git a/packages/types/src/validation.ts b/packages/types/src/validation.ts index 7fe8f9c..a7f64a9 100644 --- a/packages/types/src/validation.ts +++ b/packages/types/src/validation.ts @@ -86,7 +86,7 @@ export type CreateTemplateInput = z.infer; export type UpdateTemplateInput = z.infer; // Settings validation schemas -export const llmProviderSchema = z.enum(['openai', 'anthropic', 'ollama']); +export const llmProviderSchema = z.enum(['openai', 'anthropic', 'ollama', 'lmstudio']); export const scheduleFrequencySchema = z.enum(['daily', 'weekly']); // Time format validation (HH:MM in 24-hour format) @@ -98,6 +98,7 @@ export const updateSettingsSchema = z.object({ llmModel: z.string().max(100).nullable().optional(), llmTemperature: z.number().min(0).max(2).optional(), ollamaBaseUrl: z.string().url().max(500).optional(), + lmstudioBaseUrl: z.string().url().max(500).optional(), // Local Whisper (whisper.cpp server) whisperEnabled: z.boolean().optional(),