diff --git a/backend/src/index.ts b/backend/src/index.ts index d3c6cf5..7407c49 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -122,6 +122,12 @@ async function runMigrations() { IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'ai_verification_enabled') THEN ALTER TABLE users ADD COLUMN ai_verification_enabled BOOLEAN DEFAULT false; END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'anthropic_model') THEN + ALTER TABLE users ADD COLUMN anthropic_model TEXT; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'openai_model') THEN + ALTER TABLE users ADD COLUMN openai_model TEXT; + END IF; IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'notifications_cleared_at') THEN ALTER TABLE users ADD COLUMN notifications_cleared_at TIMESTAMP; END IF; diff --git a/backend/src/models/index.ts b/backend/src/models/index.ts index f69a3a3..7e96943 100644 --- a/backend/src/models/index.ts +++ b/backend/src/models/index.ts @@ -39,7 +39,9 @@ export interface AISettings { ai_verification_enabled: boolean; ai_provider: 'anthropic' | 'openai' | 'ollama' | null; anthropic_api_key: string | null; + anthropic_model: string | null; openai_api_key: string | null; + openai_model: string | null; ollama_base_url: string | null; ollama_model: string | null; } @@ -211,7 +213,8 @@ export const userQueries = { getAISettings: async (id: number): Promise => { const result = await pool.query( `SELECT ai_enabled, COALESCE(ai_verification_enabled, false) as ai_verification_enabled, - ai_provider, anthropic_api_key, openai_api_key, ollama_base_url, ollama_model + ai_provider, anthropic_api_key, anthropic_model, openai_api_key, openai_model, + ollama_base_url, ollama_model FROM users WHERE id = $1`, [id] ); @@ -242,10 +245,18 @@ export const userQueries = { fields.push(`anthropic_api_key = $${paramIndex++}`); values.push(settings.anthropic_api_key); } + if (settings.anthropic_model !== undefined) { + fields.push(`anthropic_model = $${paramIndex++}`); + values.push(settings.anthropic_model); + } if (settings.openai_api_key !== undefined) { fields.push(`openai_api_key = $${paramIndex++}`); values.push(settings.openai_api_key); } + if (settings.openai_model !== undefined) { + fields.push(`openai_model = $${paramIndex++}`); + values.push(settings.openai_model); + } if (settings.ollama_base_url !== undefined) { fields.push(`ollama_base_url = $${paramIndex++}`); values.push(settings.ollama_base_url); @@ -261,7 +272,8 @@ export const userQueries = { const result = await pool.query( `UPDATE users SET ${fields.join(', ')} WHERE id = $${paramIndex} RETURNING ai_enabled, COALESCE(ai_verification_enabled, false) as ai_verification_enabled, - ai_provider, anthropic_api_key, openai_api_key, ollama_base_url, ollama_model`, + ai_provider, anthropic_api_key, anthropic_model, openai_api_key, openai_model, + ollama_base_url, ollama_model`, values ); return result.rows[0] || null; diff --git a/backend/src/services/ai-extractor.ts b/backend/src/services/ai-extractor.ts index a105596..18e97f5 100644 --- a/backend/src/services/ai-extractor.ts +++ b/backend/src/services/ai-extractor.ts @@ -164,16 +164,22 @@ function prepareHtmlForAI(html: string): string { return finalContent; } +// Default models to use if user hasn't selected one +const DEFAULT_ANTHROPIC_MODEL = 'claude-haiku-4-5-20251001'; +const DEFAULT_OPENAI_MODEL = 'gpt-4.1-nano-2025-04-14'; + async function extractWithAnthropic( html: string, - apiKey: string + apiKey: string, + model?: string | null ): Promise { const anthropic = new Anthropic({ apiKey }); const preparedHtml = prepareHtmlForAI(html); + const modelToUse = model || DEFAULT_ANTHROPIC_MODEL; const response = await anthropic.messages.create({ - model: 'claude-3-5-haiku-20241022', + model: modelToUse, max_tokens: 1024, messages: [ { @@ -193,14 +199,16 @@ async function extractWithAnthropic( async function extractWithOpenAI( html: string, - apiKey: string + apiKey: string, + model?: string | null ): Promise { const openai = new OpenAI({ apiKey }); const preparedHtml = prepareHtmlForAI(html); + const modelToUse = model || DEFAULT_OPENAI_MODEL; const response = await openai.chat.completions.create({ - model: 'gpt-4o-mini', + model: modelToUse, max_tokens: 1024, messages: [ { @@ -259,7 +267,8 @@ async function verifyWithAnthropic( html: string, scrapedPrice: number, currency: string, - apiKey: string + apiKey: string, + model?: string | null ): Promise { const anthropic = new Anthropic({ apiKey }); @@ -267,9 +276,10 @@ async function verifyWithAnthropic( const prompt = VERIFICATION_PROMPT .replace('$SCRAPED_PRICE$', scrapedPrice.toString()) .replace('$CURRENCY$', currency) + preparedHtml; + const modelToUse = model || DEFAULT_ANTHROPIC_MODEL; const response = await anthropic.messages.create({ - model: 'claude-3-5-haiku-20241022', + model: modelToUse, max_tokens: 512, messages: [{ role: 'user', content: prompt }], }); @@ -286,7 +296,8 @@ async function verifyWithOpenAI( html: string, scrapedPrice: number, currency: string, - apiKey: string + apiKey: string, + model?: string | null ): Promise { const openai = new OpenAI({ apiKey }); @@ -294,9 +305,10 @@ async function verifyWithOpenAI( const prompt = VERIFICATION_PROMPT .replace('$SCRAPED_PRICE$', scrapedPrice.toString()) .replace('$CURRENCY$', currency) + preparedHtml; + const modelToUse = model || DEFAULT_OPENAI_MODEL; const response = await openai.chat.completions.create({ - model: 'gpt-4o-mini', + model: modelToUse, max_tokens: 512, messages: [{ role: 'user', content: prompt }], }); @@ -498,9 +510,9 @@ export async function extractWithAI( // Use the configured provider if (settings.ai_provider === 'anthropic' && settings.anthropic_api_key) { - return extractWithAnthropic(html, settings.anthropic_api_key); + return extractWithAnthropic(html, settings.anthropic_api_key, settings.anthropic_model); } else if (settings.ai_provider === 'openai' && settings.openai_api_key) { - return extractWithOpenAI(html, settings.openai_api_key); + return extractWithOpenAI(html, settings.openai_api_key, settings.openai_model); } else if (settings.ai_provider === 'ollama' && settings.ollama_base_url && settings.ollama_model) { return extractWithOllama(html, settings.ollama_base_url, settings.ollama_model); } @@ -525,11 +537,13 @@ export async function tryAIExtraction( // Use the configured provider if (settings.ai_provider === 'anthropic' && settings.anthropic_api_key) { - console.log(`[AI] Using Anthropic for ${url}`); - return await extractWithAnthropic(html, settings.anthropic_api_key); + const modelToUse = settings.anthropic_model || DEFAULT_ANTHROPIC_MODEL; + console.log(`[AI] Using Anthropic (${modelToUse}) for ${url}`); + return await extractWithAnthropic(html, settings.anthropic_api_key, settings.anthropic_model); } else if (settings.ai_provider === 'openai' && settings.openai_api_key) { - console.log(`[AI] Using OpenAI for ${url}`); - return await extractWithOpenAI(html, settings.openai_api_key); + const modelToUse = settings.openai_model || DEFAULT_OPENAI_MODEL; + console.log(`[AI] Using OpenAI (${modelToUse}) for ${url}`); + return await extractWithOpenAI(html, settings.openai_api_key, settings.openai_model); } else if (settings.ai_provider === 'ollama' && settings.ollama_base_url && settings.ollama_model) { console.log(`[AI] Using Ollama (${settings.ollama_model}) for ${url}`); return await extractWithOllama(html, settings.ollama_base_url, settings.ollama_model); @@ -561,13 +575,15 @@ export async function tryAIVerification( // Need a configured provider if (settings.ai_provider === 'anthropic' && settings.anthropic_api_key) { - console.log(`[AI Verify] Using Anthropic to verify $${scrapedPrice} for ${url}`); - return await verifyWithAnthropic(html, scrapedPrice, currency, settings.anthropic_api_key); + const modelToUse = settings.anthropic_model || DEFAULT_ANTHROPIC_MODEL; + console.log(`[AI Verify] Using Anthropic (${modelToUse}) to verify $${scrapedPrice} for ${url}`); + return await verifyWithAnthropic(html, scrapedPrice, currency, settings.anthropic_api_key, settings.anthropic_model); } else if (settings.ai_provider === 'openai' && settings.openai_api_key) { - console.log(`[AI Verify] Using OpenAI to verify $${scrapedPrice} for ${url}`); - return await verifyWithOpenAI(html, scrapedPrice, currency, settings.openai_api_key); + const modelToUse = settings.openai_model || DEFAULT_OPENAI_MODEL; + console.log(`[AI Verify] Using OpenAI (${modelToUse}) to verify $${scrapedPrice} for ${url}`); + return await verifyWithOpenAI(html, scrapedPrice, currency, settings.openai_api_key, settings.openai_model); } else if (settings.ai_provider === 'ollama' && settings.ollama_base_url && settings.ollama_model) { - console.log(`[AI Verify] Using Ollama to verify $${scrapedPrice} for ${url}`); + console.log(`[AI Verify] Using Ollama (${settings.ollama_model}) to verify $${scrapedPrice} for ${url}`); return await verifyWithOllama(html, scrapedPrice, currency, settings.ollama_base_url, settings.ollama_model); } @@ -613,7 +629,8 @@ export interface AIArbitrationResult { async function arbitrateWithAnthropic( html: string, candidates: PriceCandidate[], - apiKey: string + apiKey: string, + model?: string | null ): Promise { const anthropic = new Anthropic({ apiKey }); @@ -623,9 +640,10 @@ async function arbitrateWithAnthropic( const preparedHtml = prepareHtmlForAI(html); const prompt = ARBITRATION_PROMPT.replace('$CANDIDATES$', candidatesList) + preparedHtml; + const modelToUse = model || DEFAULT_ANTHROPIC_MODEL; const response = await anthropic.messages.create({ - model: 'claude-3-5-haiku-20241022', + model: modelToUse, max_tokens: 512, messages: [{ role: 'user', content: prompt }], }); @@ -641,7 +659,8 @@ async function arbitrateWithAnthropic( async function arbitrateWithOpenAI( html: string, candidates: PriceCandidate[], - apiKey: string + apiKey: string, + model?: string | null ): Promise { const openai = new OpenAI({ apiKey }); @@ -651,9 +670,10 @@ async function arbitrateWithOpenAI( const preparedHtml = prepareHtmlForAI(html); const prompt = ARBITRATION_PROMPT.replace('$CANDIDATES$', candidatesList) + preparedHtml; + const modelToUse = model || DEFAULT_OPENAI_MODEL; const response = await openai.chat.completions.create({ - model: 'gpt-4o-mini', + model: modelToUse, max_tokens: 512, messages: [{ role: 'user', content: prompt }], }); @@ -769,13 +789,15 @@ export async function tryAIArbitration( // Use the configured provider if (settings.ai_provider === 'anthropic' && settings.anthropic_api_key) { - console.log(`[AI Arbitrate] Using Anthropic to arbitrate ${candidates.length} prices for ${url}`); - return await arbitrateWithAnthropic(html, candidates, settings.anthropic_api_key); + const modelToUse = settings.anthropic_model || DEFAULT_ANTHROPIC_MODEL; + console.log(`[AI Arbitrate] Using Anthropic (${modelToUse}) to arbitrate ${candidates.length} prices for ${url}`); + return await arbitrateWithAnthropic(html, candidates, settings.anthropic_api_key, settings.anthropic_model); } else if (settings.ai_provider === 'openai' && settings.openai_api_key) { - console.log(`[AI Arbitrate] Using OpenAI to arbitrate ${candidates.length} prices for ${url}`); - return await arbitrateWithOpenAI(html, candidates, settings.openai_api_key); + const modelToUse = settings.openai_model || DEFAULT_OPENAI_MODEL; + console.log(`[AI Arbitrate] Using OpenAI (${modelToUse}) to arbitrate ${candidates.length} prices for ${url}`); + return await arbitrateWithOpenAI(html, candidates, settings.openai_api_key, settings.openai_model); } else if (settings.ai_provider === 'ollama' && settings.ollama_base_url && settings.ollama_model) { - console.log(`[AI Arbitrate] Using Ollama to arbitrate ${candidates.length} prices for ${url}`); + console.log(`[AI Arbitrate] Using Ollama (${settings.ollama_model}) to arbitrate ${candidates.length} prices for ${url}`); return await arbitrateWithOllama(html, candidates, settings.ollama_base_url, settings.ollama_model); } diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 1ec4122..6658fee 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -229,7 +229,9 @@ export const settingsApi = { ai_verification_enabled?: boolean; ai_provider?: 'anthropic' | 'openai' | 'ollama' | null; anthropic_api_key?: string | null; + anthropic_model?: string | null; openai_api_key?: string | null; + openai_model?: string | null; ollama_base_url?: string | null; ollama_model?: string | null; }) => api.put('/settings/ai', data), @@ -247,7 +249,9 @@ export interface AISettings { ai_verification_enabled: boolean; ai_provider: 'anthropic' | 'openai' | 'ollama' | null; anthropic_api_key: string | null; + anthropic_model: string | null; openai_api_key: string | null; + openai_model: string | null; ollama_base_url: string | null; ollama_model: string | null; } diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 604f1c9..dc8cfe8 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -56,7 +56,9 @@ export default function Settings() { const [aiVerificationEnabled, setAIVerificationEnabled] = useState(false); const [aiProvider, setAIProvider] = useState<'anthropic' | 'openai' | 'ollama'>('anthropic'); const [anthropicApiKey, setAnthropicApiKey] = useState(''); + const [anthropicModel, setAnthropicModel] = useState(''); const [openaiApiKey, setOpenaiApiKey] = useState(''); + const [openaiModel, setOpenaiModel] = useState(''); const [ollamaBaseUrl, setOllamaBaseUrl] = useState(''); const [ollamaModel, setOllamaModel] = useState(''); const [availableOllamaModels, setAvailableOllamaModels] = useState([]); @@ -114,7 +116,9 @@ export default function Settings() { setAIProvider(aiRes.data.ai_provider); } setAnthropicApiKey(aiRes.data.anthropic_api_key || ''); + setAnthropicModel(aiRes.data.anthropic_model || ''); setOpenaiApiKey(aiRes.data.openai_api_key || ''); + setOpenaiModel(aiRes.data.openai_model || ''); setOllamaBaseUrl(aiRes.data.ollama_base_url || ''); setOllamaModel(aiRes.data.ollama_model || ''); } catch { @@ -369,12 +373,16 @@ export default function Settings() { ai_verification_enabled: aiVerificationEnabled, ai_provider: aiProvider, anthropic_api_key: anthropicApiKey || undefined, + anthropic_model: aiProvider === 'anthropic' ? anthropicModel || null : undefined, openai_api_key: openaiApiKey || undefined, + openai_model: aiProvider === 'openai' ? openaiModel || null : undefined, ollama_base_url: aiProvider === 'ollama' ? ollamaBaseUrl || null : undefined, ollama_model: aiProvider === 'ollama' ? ollamaModel || null : undefined, }); setAISettings(response.data); setAIVerificationEnabled(response.data.ai_verification_enabled ?? false); + setAnthropicModel(response.data.anthropic_model || ''); + setOpenaiModel(response.data.openai_model || ''); setAnthropicApiKey(''); setOpenaiApiKey(''); setSuccess('AI settings saved successfully'); @@ -1430,37 +1438,99 @@ export default function Settings() { {aiProvider === 'anthropic' && ( -
- - setAnthropicApiKey(e.target.value)} - placeholder="sk-ant-..." - /> -

- Get your API key from{' '} - - console.anthropic.com - -

-
+ <> +
+ + setAnthropicApiKey(e.target.value)} + placeholder="sk-ant-..." + /> +

+ Get your API key from{' '} + + console.anthropic.com + +

+
+ +
+ + +

+ Choose a model based on your cost/accuracy needs. Haiku is fastest and cheapest, Opus is most accurate but expensive. + {aiSettings?.anthropic_model && ` (currently: ${aiSettings.anthropic_model})`} +

+
+ )} {aiProvider === 'openai' && ( -
- - setOpenaiApiKey(e.target.value)} - placeholder="sk-..." - /> -

- Get your API key from{' '} - - platform.openai.com - -

-
+ <> +
+ + setOpenaiApiKey(e.target.value)} + placeholder="sk-..." + /> +

+ Get your API key from{' '} + + platform.openai.com + +

+
+ +
+ + +

+ Choose a model based on your cost/accuracy needs. GPT-4.1 Nano is fastest and cheapest. + {aiSettings?.openai_model && ` (currently: ${aiSettings.openai_model})`} +

+
+ )} {aiProvider === 'ollama' && (