From 014bb15890089d1601a25b7b1c857d024e37ecbc Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 16 Feb 2026 15:34:59 +0100 Subject: [PATCH] Reduce AI costs: switch tagging to gpt-4o-mini, add custom base URL support - Change AI tagging to use AI_MODELS.QUICK (gpt-4o-mini) instead of gpt-4o for 10-15x cost reduction on classification tasks - Add openai_base_url system setting for OpenAI-compatible providers (OpenRouter, Groq, Together AI, local models) - Reset OpenAI client singleton when API key, base URL, or model changes - Add base URL field to AI settings form with provider examples Co-Authored-By: Claude Opus 4.6 --- src/components/settings/ai-settings-form.tsx | 27 +++++++++++++++ src/components/settings/settings-content.tsx | 1 + src/lib/openai.ts | 36 +++++++++++++++++++- src/server/routers/settings.ts | 6 ++++ src/server/services/ai-tagging.ts | 8 +++-- 5 files changed, 74 insertions(+), 4 deletions(-) diff --git a/src/components/settings/ai-settings-form.tsx b/src/components/settings/ai-settings-form.tsx index df10f00..8ba5896 100644 --- a/src/components/settings/ai-settings-form.tsx +++ b/src/components/settings/ai-settings-form.tsx @@ -36,6 +36,7 @@ const formSchema = z.object({ ai_model: z.string(), ai_send_descriptions: z.boolean(), openai_api_key: z.string().optional(), + openai_base_url: z.string().optional(), }) type FormValues = z.infer @@ -47,6 +48,7 @@ interface AISettingsFormProps { ai_model?: string ai_send_descriptions?: string openai_api_key?: string + openai_base_url?: string } } @@ -61,6 +63,7 @@ export function AISettingsForm({ settings }: AISettingsFormProps) { ai_model: settings.ai_model || 'gpt-4o', ai_send_descriptions: settings.ai_send_descriptions === 'true', openai_api_key: '', + openai_base_url: settings.openai_base_url || '', }, }) @@ -113,6 +116,9 @@ export function AISettingsForm({ settings }: AISettingsFormProps) { settingsToUpdate.push({ key: 'openai_api_key', value: data.openai_api_key }) } + // Save base URL (empty string clears it) + settingsToUpdate.push({ key: 'openai_base_url', value: data.openai_base_url?.trim() || '' }) + updateSettings.mutate({ settings: settingsToUpdate }) } @@ -208,6 +214,27 @@ export function AISettingsForm({ settings }: AISettingsFormProps) { )} /> + ( + + API Base URL (Optional) + + + + + Custom base URL for OpenAI-compatible providers. Leave blank for OpenAI. + Use https://openrouter.ai/api/v1 for OpenRouter (access Claude, Gemini, Llama, etc.) + + + + )} + /> + { } /** - * Create OpenAI client instance + * Get custom base URL for OpenAI-compatible providers. + * Supports OpenRouter, Together AI, Groq, local models, etc. + * Set via Settings → AI or OPENAI_BASE_URL env var. + */ +async function getBaseURL(): Promise { + try { + const setting = await prisma.systemSettings.findUnique({ + where: { key: 'openai_base_url' }, + }) + return setting?.value || process.env.OPENAI_BASE_URL || undefined + } catch { + return process.env.OPENAI_BASE_URL || undefined + } +} + +/** + * Create OpenAI client instance. + * Supports custom baseURL for OpenAI-compatible providers + * (OpenRouter, Groq, Together AI, local models, etc.) */ async function createOpenAIClient(): Promise { const apiKey = await getOpenAIApiKey() @@ -197,8 +215,15 @@ async function createOpenAIClient(): Promise { return null } + const baseURL = await getBaseURL() + + if (baseURL) { + console.log(`[OpenAI] Using custom base URL: ${baseURL}`) + } + return new OpenAI({ apiKey, + ...(baseURL ? { baseURL } : {}), }) } @@ -221,6 +246,15 @@ export async function getOpenAI(): Promise { return client } +/** + * Reset the OpenAI client singleton (e.g., after settings change). + * Next call to getOpenAI() will create a fresh client. + */ +export function resetOpenAIClient(): void { + globalForOpenAI.openai = undefined + globalForOpenAI.openaiInitialized = false +} + /** * Check if OpenAI is configured and available */ diff --git a/src/server/routers/settings.ts b/src/server/routers/settings.ts index 27bbf60..11318cc 100644 --- a/src/server/routers/settings.ts +++ b/src/server/routers/settings.ts @@ -201,6 +201,12 @@ export const settingsRouter = router({ clearStorageProviderCache() } + // Reset OpenAI client if API key or base URL changed + if (input.settings.some((s) => s.key === 'openai_api_key' || s.key === 'openai_base_url' || s.key === 'ai_model')) { + const { resetOpenAIClient } = await import('@/lib/openai') + resetOpenAIClient() + } + // Audit log await logAudit({ prisma: ctx.prisma, diff --git a/src/server/services/ai-tagging.ts b/src/server/services/ai-tagging.ts index 1115eee..d0a7050 100644 --- a/src/server/services/ai-tagging.ts +++ b/src/server/services/ai-tagging.ts @@ -16,7 +16,7 @@ */ import { prisma } from '@/lib/prisma' -import { getOpenAI, getConfiguredModel, buildCompletionParams } from '@/lib/openai' +import { getOpenAI, getConfiguredModel, buildCompletionParams, AI_MODELS } from '@/lib/openai' import { logAIUsage, extractTokenUsage } from '@/server/utils/ai-usage' import { classifyAIError, createParseError, logAIError } from './ai-errors' import { @@ -178,7 +178,8 @@ async function getAISuggestions( return { suggestions: [], tokensUsed: 0 } } - const model = await getConfiguredModel() + // Use QUICK model — tag classification is simple, doesn't need expensive reasoning + const model = await getConfiguredModel(AI_MODELS.QUICK) // Build compact tag list for prompt const tagList = availableTags.map((t) => ({ @@ -294,7 +295,8 @@ async function getAISuggestionsBatch( return { suggestionsMap: new Map(), tokensUsed: 0 } } - const model = await getConfiguredModel() + // Use QUICK model — tag classification is simple, doesn't need expensive reasoning + const model = await getConfiguredModel(AI_MODELS.QUICK) const suggestionsMap = new Map() // Build compact tag list (sent once for entire batch)