feat: add openai_base_url config for LiteLLM/OpenAI-compatible proxy support

This commit is contained in:
Tony Fruzza 2026-06-15 07:07:07 +00:00
parent 945375eca8
commit 89c71a3dde
9 changed files with 383 additions and 296 deletions

View file

@ -1,12 +1,12 @@
{ {
"name": "priceghost-backend", "name": "priceghost-backend",
"version": "1.0.5", "version": "1.0.6",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "priceghost-backend", "name": "priceghost-backend",
"version": "1.0.5", "version": "1.0.6",
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.24.0", "@anthropic-ai/sdk": "^0.24.0",
"@google/generative-ai": "^0.24.1", "@google/generative-ai": "^0.24.1",

View file

@ -48,6 +48,7 @@ export interface AISettings {
anthropic_model: string | null; anthropic_model: string | null;
openai_api_key: string | null; openai_api_key: string | null;
openai_model: string | null; openai_model: string | null;
openai_base_url: string | null;
ollama_base_url: string | null; ollama_base_url: string | null;
ollama_model: string | null; ollama_model: string | null;
gemini_api_key: string | null; gemini_api_key: string | null;
@ -244,11 +245,11 @@ export const userQueries = {
return (result.rowCount ?? 0) > 0; return (result.rowCount ?? 0) > 0;
}, },
getAISettings: async (id: number): Promise<AISettings | null> => { getAISettings: async (id: number): Promise<AISettings | null> => {
const result = await pool.query( const result = await pool.query(
`SELECT ai_enabled, COALESCE(ai_verification_enabled, false) as ai_verification_enabled, `SELECT ai_enabled, COALESCE(ai_verification_enabled, false) as ai_verification_enabled,
ai_provider, anthropic_api_key, anthropic_model, openai_api_key, openai_model, ai_provider, anthropic_api_key, anthropic_model, openai_api_key, openai_model,
ollama_base_url, ollama_model, gemini_api_key, gemini_model openai_base_url, ollama_base_url, ollama_model, gemini_api_key, gemini_model
FROM users WHERE id = $1`, FROM users WHERE id = $1`,
[id] [id]
); );
@ -291,6 +292,10 @@ export const userQueries = {
fields.push(`openai_model = $${paramIndex++}`); fields.push(`openai_model = $${paramIndex++}`);
values.push(settings.openai_model); values.push(settings.openai_model);
} }
if (settings.openai_base_url !== undefined) {
fields.push(`openai_base_url = $${paramIndex++}`);
values.push(settings.openai_base_url);
}
if (settings.ollama_base_url !== undefined) { if (settings.ollama_base_url !== undefined) {
fields.push(`ollama_base_url = $${paramIndex++}`); fields.push(`ollama_base_url = $${paramIndex++}`);
values.push(settings.ollama_base_url); values.push(settings.ollama_base_url);
@ -315,7 +320,7 @@ export const userQueries = {
`UPDATE users SET ${fields.join(', ')} WHERE id = $${paramIndex} `UPDATE users SET ${fields.join(', ')} WHERE id = $${paramIndex}
RETURNING ai_enabled, COALESCE(ai_verification_enabled, false) as ai_verification_enabled, RETURNING ai_enabled, COALESCE(ai_verification_enabled, false) as ai_verification_enabled,
ai_provider, anthropic_api_key, anthropic_model, openai_api_key, openai_model, ai_provider, anthropic_api_key, anthropic_model, openai_api_key, openai_model,
ollama_base_url, ollama_model, gemini_api_key, gemini_model`, openai_base_url, ollama_base_url, ollama_model, gemini_api_key, gemini_model`,
values values
); );
return result.rows[0] || null; return result.rows[0] || null;

View file

@ -335,6 +335,7 @@ router.get('/ai', async (req: AuthRequest, res: Response) => {
anthropic_model: settings.anthropic_model || null, anthropic_model: settings.anthropic_model || null,
openai_api_key: settings.openai_api_key || null, openai_api_key: settings.openai_api_key || null,
openai_model: settings.openai_model || null, openai_model: settings.openai_model || null,
openai_base_url: settings.openai_base_url || null,
ollama_base_url: settings.ollama_base_url || null, ollama_base_url: settings.ollama_base_url || null,
ollama_model: settings.ollama_model || null, ollama_model: settings.ollama_model || null,
gemini_api_key: settings.gemini_api_key || null, gemini_api_key: settings.gemini_api_key || null,
@ -358,6 +359,7 @@ router.put('/ai', async (req: AuthRequest, res: Response) => {
anthropic_model, anthropic_model,
openai_api_key, openai_api_key,
openai_model, openai_model,
openai_base_url,
ollama_base_url, ollama_base_url,
ollama_model, ollama_model,
gemini_api_key, gemini_api_key,
@ -372,6 +374,7 @@ router.put('/ai', async (req: AuthRequest, res: Response) => {
anthropic_model, anthropic_model,
openai_api_key, openai_api_key,
openai_model, openai_model,
openai_base_url,
ollama_base_url, ollama_base_url,
ollama_model, ollama_model,
gemini_api_key, gemini_api_key,
@ -391,6 +394,7 @@ router.put('/ai', async (req: AuthRequest, res: Response) => {
anthropic_model: settings.anthropic_model || null, anthropic_model: settings.anthropic_model || null,
openai_api_key: settings.openai_api_key || null, openai_api_key: settings.openai_api_key || null,
openai_model: settings.openai_model || null, openai_model: settings.openai_model || null,
openai_base_url: settings.openai_base_url || null,
ollama_base_url: settings.ollama_base_url || null, ollama_base_url: settings.ollama_base_url || null,
ollama_model: settings.ollama_model || null, ollama_model: settings.ollama_model || null,
gemini_api_key: settings.gemini_api_key || null, gemini_api_key: settings.gemini_api_key || null,

View file

@ -253,9 +253,12 @@ async function extractWithAnthropic(
async function extractWithOpenAI( async function extractWithOpenAI(
html: string, html: string,
apiKey: string, apiKey: string,
model?: string | null model?: string | null,
baseURL?: string | null
): Promise<AIExtractionResult> { ): Promise<AIExtractionResult> {
const openai = new OpenAI({ apiKey }); const openaiConfig: Record<string, string> = { apiKey };
if (baseURL) openaiConfig.baseURL = baseURL;
const openai = new OpenAI(openaiConfig);
const preparedHtml = prepareHtmlForAI(html); const preparedHtml = prepareHtmlForAI(html);
const modelToUse = model || DEFAULT_OPENAI_MODEL; const modelToUse = model || DEFAULT_OPENAI_MODEL;
@ -383,9 +386,12 @@ async function verifyWithOpenAI(
scrapedPrice: number, scrapedPrice: number,
currency: string, currency: string,
apiKey: string, apiKey: string,
model?: string | null model?: string | null,
baseURL?: string | null
): Promise<AIVerificationResult> { ): Promise<AIVerificationResult> {
const openai = new OpenAI({ apiKey }); const openaiConfig: Record<string, string> = { apiKey };
if (baseURL) openaiConfig.baseURL = baseURL;
const openai = new OpenAI(openaiConfig);
const preparedHtml = prepareHtmlForAI(html); const preparedHtml = prepareHtmlForAI(html);
const prompt = VERIFICATION_PROMPT const prompt = VERIFICATION_PROMPT
@ -509,9 +515,12 @@ async function verifyStockStatusWithOpenAI(
variantPrice: number, variantPrice: number,
currency: string, currency: string,
apiKey: string, apiKey: string,
model?: string | null model?: string | null,
baseURL?: string | null
): Promise<AIStockStatusResult> { ): Promise<AIStockStatusResult> {
const openai = new OpenAI({ apiKey }); const openaiConfig: Record<string, string> = { apiKey };
if (baseURL) openaiConfig.baseURL = baseURL;
const openai = new OpenAI(openaiConfig);
const preparedHtml = prepareHtmlForAI(html); const preparedHtml = prepareHtmlForAI(html);
const prompt = STOCK_STATUS_PROMPT const prompt = STOCK_STATUS_PROMPT
@ -808,7 +817,7 @@ export async function extractWithAI(
if (settings.ai_provider === 'anthropic' && settings.anthropic_api_key) { if (settings.ai_provider === 'anthropic' && settings.anthropic_api_key) {
return extractWithAnthropic(html, settings.anthropic_api_key, settings.anthropic_model); return extractWithAnthropic(html, settings.anthropic_api_key, settings.anthropic_model);
} else if (settings.ai_provider === 'openai' && settings.openai_api_key) { } else if (settings.ai_provider === 'openai' && settings.openai_api_key) {
return extractWithOpenAI(html, settings.openai_api_key, settings.openai_model); return extractWithOpenAI(html, settings.openai_api_key, settings.openai_model, settings.openai_base_url);
} else if (settings.ai_provider === 'ollama' && settings.ollama_base_url && settings.ollama_model) { } else if (settings.ai_provider === 'ollama' && settings.ollama_base_url && settings.ollama_model) {
return extractWithOllama(html, settings.ollama_base_url, settings.ollama_model); return extractWithOllama(html, settings.ollama_base_url, settings.ollama_model);
} else if (settings.ai_provider === 'gemini' && settings.gemini_api_key) { } else if (settings.ai_provider === 'gemini' && settings.gemini_api_key) {
@ -841,7 +850,7 @@ export async function tryAIExtraction(
} else if (settings.ai_provider === 'openai' && settings.openai_api_key) { } else if (settings.ai_provider === 'openai' && settings.openai_api_key) {
const modelToUse = settings.openai_model || DEFAULT_OPENAI_MODEL; const modelToUse = settings.openai_model || DEFAULT_OPENAI_MODEL;
console.log(`[AI] Using OpenAI (${modelToUse}) for ${url}`); console.log(`[AI] Using OpenAI (${modelToUse}) for ${url}`);
return await extractWithOpenAI(html, settings.openai_api_key, settings.openai_model); return await extractWithOpenAI(html, settings.openai_api_key, settings.openai_model, settings.openai_base_url);
} else if (settings.ai_provider === 'ollama' && settings.ollama_base_url && settings.ollama_model) { } else if (settings.ai_provider === 'ollama' && settings.ollama_base_url && settings.ollama_model) {
console.log(`[AI] Using Ollama (${settings.ollama_model}) for ${url}`); console.log(`[AI] Using Ollama (${settings.ollama_model}) for ${url}`);
return await extractWithOllama(html, settings.ollama_base_url, settings.ollama_model); return await extractWithOllama(html, settings.ollama_base_url, settings.ollama_model);
@ -883,7 +892,7 @@ export async function tryAIVerification(
} else if (settings.ai_provider === 'openai' && settings.openai_api_key) { } else if (settings.ai_provider === 'openai' && settings.openai_api_key) {
const modelToUse = settings.openai_model || DEFAULT_OPENAI_MODEL; const modelToUse = settings.openai_model || DEFAULT_OPENAI_MODEL;
console.log(`[AI Verify] Using OpenAI (${modelToUse}) to verify $${scrapedPrice} for ${url}`); 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); return await verifyWithOpenAI(html, scrapedPrice, currency, settings.openai_api_key, settings.openai_model, settings.openai_base_url);
} else if (settings.ai_provider === 'ollama' && settings.ollama_base_url && settings.ollama_model) { } else if (settings.ai_provider === 'ollama' && settings.ollama_base_url && settings.ollama_model) {
console.log(`[AI Verify] Using Ollama (${settings.ollama_model}) 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); return await verifyWithOllama(html, scrapedPrice, currency, settings.ollama_base_url, settings.ollama_model);
@ -926,7 +935,7 @@ export async function tryAIStockStatusVerification(
} else if (settings.ai_provider === 'openai' && settings.openai_api_key) { } else if (settings.ai_provider === 'openai' && settings.openai_api_key) {
const modelToUse = settings.openai_model || DEFAULT_OPENAI_MODEL; const modelToUse = settings.openai_model || DEFAULT_OPENAI_MODEL;
console.log(`[AI Stock] Using OpenAI (${modelToUse}) to verify stock status for $${variantPrice} variant at ${url}`); console.log(`[AI Stock] Using OpenAI (${modelToUse}) to verify stock status for $${variantPrice} variant at ${url}`);
return await verifyStockStatusWithOpenAI(html, variantPrice, currency, settings.openai_api_key, settings.openai_model); return await verifyStockStatusWithOpenAI(html, variantPrice, currency, settings.openai_api_key, settings.openai_model, settings.openai_base_url);
} else if (settings.ai_provider === 'ollama' && settings.ollama_base_url && settings.ollama_model) { } else if (settings.ai_provider === 'ollama' && settings.ollama_base_url && settings.ollama_model) {
console.log(`[AI Stock] Using Ollama (${settings.ollama_model}) to verify stock status for $${variantPrice} variant at ${url}`); console.log(`[AI Stock] Using Ollama (${settings.ollama_model}) to verify stock status for $${variantPrice} variant at ${url}`);
return await verifyStockStatusWithOllama(html, variantPrice, currency, settings.ollama_base_url, settings.ollama_model); return await verifyStockStatusWithOllama(html, variantPrice, currency, settings.ollama_base_url, settings.ollama_model);
@ -1009,9 +1018,12 @@ async function arbitrateWithOpenAI(
html: string, html: string,
candidates: PriceCandidate[], candidates: PriceCandidate[],
apiKey: string, apiKey: string,
model?: string | null model?: string | null,
baseURL?: string | null
): Promise<AIArbitrationResult> { ): Promise<AIArbitrationResult> {
const openai = new OpenAI({ apiKey }); const openaiConfig: Record<string, string> = { apiKey };
if (baseURL) openaiConfig.baseURL = baseURL;
const openai = new OpenAI(openaiConfig);
const candidatesList = candidates.map((c, i) => const candidatesList = candidates.map((c, i) =>
`${i}. ${c.price} ${c.currency} (method: ${c.method}, context: ${c.context || 'none'})` `${i}. ${c.price} ${c.currency} (method: ${c.method}, context: ${c.context || 'none'})`
@ -1180,7 +1192,7 @@ export async function tryAIArbitration(
} else if (settings.ai_provider === 'openai' && settings.openai_api_key) { } else if (settings.ai_provider === 'openai' && settings.openai_api_key) {
const modelToUse = settings.openai_model || DEFAULT_OPENAI_MODEL; const modelToUse = settings.openai_model || DEFAULT_OPENAI_MODEL;
console.log(`[AI Arbitrate] Using OpenAI (${modelToUse}) to arbitrate ${candidates.length} prices for ${url}`); 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); return await arbitrateWithOpenAI(html, candidates, settings.openai_api_key, settings.openai_model, settings.openai_base_url);
} else if (settings.ai_provider === 'ollama' && settings.ollama_base_url && settings.ollama_model) { } else if (settings.ai_provider === 'ollama' && settings.ollama_base_url && settings.ollama_model) {
console.log(`[AI Arbitrate] Using Ollama (${settings.ollama_model}) 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); return await arbitrateWithOllama(html, candidates, settings.ollama_base_url, settings.ollama_model);

View file

@ -119,6 +119,17 @@ BEGIN
END IF; END IF;
END $$; END $$;
-- Migration: Add AI base URL columns to users if they don't exist
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'users' AND column_name = 'openai_base_url'
) THEN
ALTER TABLE users ADD COLUMN openai_base_url VARCHAR(512);
END IF;
END $$;
-- Price history table -- Price history table
CREATE TABLE IF NOT EXISTS price_history ( CREATE TABLE IF NOT EXISTS price_history (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,

File diff suppressed because it is too large Load diff

View file

@ -259,6 +259,7 @@ export const settingsApi = {
anthropic_model?: string | null; anthropic_model?: string | null;
openai_api_key?: string | null; openai_api_key?: string | null;
openai_model?: string | null; openai_model?: string | null;
openai_base_url?: string | null;
ollama_base_url?: string | null; ollama_base_url?: string | null;
ollama_model?: string | null; ollama_model?: string | null;
gemini_api_key?: string | null; gemini_api_key?: string | null;
@ -284,6 +285,7 @@ export interface AISettings {
anthropic_model: string | null; anthropic_model: string | null;
openai_api_key: string | null; openai_api_key: string | null;
openai_model: string | null; openai_model: string | null;
openai_base_url: string | null;
ollama_base_url: string | null; ollama_base_url: string | null;
ollama_model: string | null; ollama_model: string | null;
gemini_api_key: string | null; gemini_api_key: string | null;

View file

@ -66,6 +66,7 @@ export default function Settings() {
const [anthropicModel, setAnthropicModel] = useState(''); const [anthropicModel, setAnthropicModel] = useState('');
const [openaiApiKey, setOpenaiApiKey] = useState(''); const [openaiApiKey, setOpenaiApiKey] = useState('');
const [openaiModel, setOpenaiModel] = useState(''); const [openaiModel, setOpenaiModel] = useState('');
const [openaiBaseUrl, setOpenaiBaseUrl] = useState('');
const [ollamaBaseUrl, setOllamaBaseUrl] = useState(''); const [ollamaBaseUrl, setOllamaBaseUrl] = useState('');
const [ollamaModel, setOllamaModel] = useState(''); const [ollamaModel, setOllamaModel] = useState('');
const [availableOllamaModels, setAvailableOllamaModels] = useState<string[]>([]); const [availableOllamaModels, setAvailableOllamaModels] = useState<string[]>([]);
@ -135,6 +136,7 @@ export default function Settings() {
setAnthropicModel(aiRes.data.anthropic_model || ''); setAnthropicModel(aiRes.data.anthropic_model || '');
setOpenaiApiKey(aiRes.data.openai_api_key || ''); setOpenaiApiKey(aiRes.data.openai_api_key || '');
setOpenaiModel(aiRes.data.openai_model || ''); setOpenaiModel(aiRes.data.openai_model || '');
setOpenaiBaseUrl(aiRes.data.openai_base_url || '');
setOllamaBaseUrl(aiRes.data.ollama_base_url || ''); setOllamaBaseUrl(aiRes.data.ollama_base_url || '');
setOllamaModel(aiRes.data.ollama_model || ''); setOllamaModel(aiRes.data.ollama_model || '');
setGeminiApiKey(aiRes.data.gemini_api_key || ''); setGeminiApiKey(aiRes.data.gemini_api_key || '');
@ -462,6 +464,7 @@ export default function Settings() {
anthropic_model: aiProvider === 'anthropic' ? anthropicModel || null : undefined, anthropic_model: aiProvider === 'anthropic' ? anthropicModel || null : undefined,
openai_api_key: openaiApiKey || undefined, openai_api_key: openaiApiKey || undefined,
openai_model: aiProvider === 'openai' ? openaiModel || null : undefined, openai_model: aiProvider === 'openai' ? openaiModel || null : undefined,
openai_base_url: aiProvider === 'openai' ? openaiBaseUrl || null : undefined,
ollama_base_url: aiProvider === 'ollama' ? ollamaBaseUrl || null : undefined, ollama_base_url: aiProvider === 'ollama' ? ollamaBaseUrl || null : undefined,
ollama_model: aiProvider === 'ollama' ? ollamaModel || null : undefined, ollama_model: aiProvider === 'ollama' ? ollamaModel || null : undefined,
gemini_api_key: geminiApiKey || undefined, gemini_api_key: geminiApiKey || undefined,
@ -471,6 +474,7 @@ export default function Settings() {
setAIVerificationEnabled(response.data.ai_verification_enabled ?? false); setAIVerificationEnabled(response.data.ai_verification_enabled ?? false);
setAnthropicModel(response.data.anthropic_model || ''); setAnthropicModel(response.data.anthropic_model || '');
setOpenaiModel(response.data.openai_model || ''); setOpenaiModel(response.data.openai_model || '');
setOpenaiBaseUrl(response.data.openai_base_url || '');
setGeminiModel(response.data.gemini_model || ''); setGeminiModel(response.data.gemini_model || '');
setAnthropicApiKey(''); setAnthropicApiKey('');
setOpenaiApiKey(''); setOpenaiApiKey('');
@ -1763,6 +1767,21 @@ export default function Settings() {
{aiSettings?.openai_model && ` (currently: ${aiSettings.openai_model})`} {aiSettings?.openai_model && ` (currently: ${aiSettings.openai_model})`}
</p> </p>
</div> </div>
<div className="settings-form-group">
<label>Base URL (optional for LiteLLM / OpenAI-compatible proxies)</label>
<input
type="text"
value={openaiBaseUrl}
onChange={(e) => setOpenaiBaseUrl(e.target.value)}
placeholder="https://api.openai.com/v1"
/>
<p className="hint">
Leave blank for default OpenAI API. Set to your LiteLLM proxy URL (e.g., http://litellm:4000/v1)
to use a custom model provider.
{aiSettings?.openai_base_url && ` (currently: ${aiSettings.openai_base_url})`}
</p>
</div>
</> </>
)} )}

View file

@ -1 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/components/aistatusbadge.tsx","./src/components/authform.tsx","./src/components/layout.tsx","./src/components/notificationbell.tsx","./src/components/particlebackground.tsx","./src/components/passwordinput.tsx","./src/components/pricechart.tsx","./src/components/priceselectionmodal.tsx","./src/components/productcard.tsx","./src/components/productform.tsx","./src/components/sparkline.tsx","./src/components/stocktimeline.tsx","./src/context/authcontext.tsx","./src/context/toastcontext.tsx","./src/hooks/useauth.ts","./src/pages/dashboard.tsx","./src/pages/login.tsx","./src/pages/notificationhistory.tsx","./src/pages/productdetail.tsx","./src/pages/register.tsx","./src/pages/settings.tsx"],"version":"5.9.3"} {"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/components/AIStatusBadge.tsx","./src/components/AuthForm.tsx","./src/components/Layout.tsx","./src/components/NotificationBell.tsx","./src/components/ParticleBackground.tsx","./src/components/PasswordInput.tsx","./src/components/PriceChart.tsx","./src/components/PriceSelectionModal.tsx","./src/components/ProductCard.tsx","./src/components/ProductForm.tsx","./src/components/Sparkline.tsx","./src/components/StockTimeline.tsx","./src/context/AuthContext.tsx","./src/context/ToastContext.tsx","./src/hooks/useAuth.ts","./src/pages/Dashboard.tsx","./src/pages/Login.tsx","./src/pages/NotificationHistory.tsx","./src/pages/ProductDetail.tsx","./src/pages/Register.tsx","./src/pages/Settings.tsx"],"version":"5.9.3"}