feat: Add OpenRouter as AI provider with easy switch

- Added openrouter.ts with provider configuration
- Default provider: OpenRouter with google/gemma-3-27b-it
- Switch to Gemini via AI_PROVIDER=gemini in .env.local
- Both providers use same credit system
- OpenRouter uses OpenAI-compatible API

To switch providers, set in .env.local:
AI_PROVIDER=openrouter  # default
AI_PROVIDER=gemini      # Google Gemini
This commit is contained in:
2025-12-25 23:56:24 +01:00
parent f0f36e9c03
commit fb2a8d0f7b
3 changed files with 184 additions and 54 deletions

View File

@@ -5,6 +5,7 @@ import { BottleMetadataSchema, BottleMetadata } from '@/types/whisky';
import { createClient } from '@/lib/supabase/server'; import { createClient } from '@/lib/supabase/server';
import { trackApiUsage } from '@/services/track-api-usage'; import { trackApiUsage } from '@/services/track-api-usage';
import { checkCreditBalance, deductCredits } from '@/services/credit-service'; import { checkCreditBalance, deductCredits } from '@/services/credit-service';
import { getAIProvider, getOpenRouterClient, OPENROUTER_VISION_MODEL } from '@/lib/openrouter';
// Schema for Gemini Vision extraction // Schema for Gemini Vision extraction
const visionSchema = { const visionSchema = {
@@ -32,24 +33,144 @@ export interface GeminiVisionResult {
success: boolean; success: boolean;
data?: BottleMetadata; data?: BottleMetadata;
error?: string; error?: string;
provider?: 'gemini' | 'openrouter';
perf?: { perf?: {
apiCall: number; apiCall: number;
total: number; total: number;
}; };
} }
const VISION_PROMPT = `Analyze this whisky bottle label image and extract all visible metadata.
Look carefully for:
- Brand/Distillery name
- Bottle name or expression
- Age statement (e.g., "12 Years Old")
- ABV/Alcohol percentage
- Vintage year (if shown)
- Cask type (e.g., Sherry, Bourbon cask)
- Bottler name (if independent bottling)
- Category (Single Malt, Blended Malt, Bourbon, etc.)
Be precise and only include information you can clearly read from the label.
If you cannot read something clearly, leave it null.
Respond ONLY with valid JSON in this format:
{
"name": "Full whisky name",
"distillery": "Distillery name or null",
"bottler": "Bottler name or null",
"category": "Whisky category or null",
"abv": 46.0,
"age": 12,
"vintage": "2010 or null",
"cask_type": "Cask type or null",
"distilled_at": "Date or null",
"bottled_at": "Date or null",
"batch_info": "Batch info or null",
"is_whisky": true,
"confidence": 0.85
}`;
/** /**
* Analyze a whisky bottle label image using Gemini Vision * Analyze whisky label with OpenRouter (Gemma 3 27B)
*/
async function analyzeWithOpenRouter(base64Data: string, mimeType: string): Promise<{ data: any; apiTime: number }> {
const client = getOpenRouterClient();
const startApi = performance.now();
const response = await client.chat.completions.create({
model: OPENROUTER_VISION_MODEL,
messages: [
{
role: 'user',
content: [
{
type: 'image_url',
image_url: {
url: `data:${mimeType};base64,${base64Data}`,
},
},
{
type: 'text',
text: VISION_PROMPT,
},
],
},
],
temperature: 0.1,
max_tokens: 1024,
});
const endApi = performance.now();
const content = response.choices[0]?.message?.content || '{}';
// Extract JSON from response (may have markdown code blocks)
let jsonStr = content;
const jsonMatch = content.match(/```(?:json)?\s*([\s\S]*?)```/);
if (jsonMatch) {
jsonStr = jsonMatch[1].trim();
}
return {
data: JSON.parse(jsonStr),
apiTime: endApi - startApi,
};
}
/**
* Analyze whisky label with Gemini
*/
async function analyzeWithGemini(base64Data: string, mimeType: string): Promise<{ data: any; apiTime: number }> {
const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY!);
const model = genAI.getGenerativeModel({
model: 'gemini-2.5-flash',
generationConfig: {
responseMimeType: "application/json",
responseSchema: visionSchema as any,
temperature: 0.1,
},
safetySettings: [
{ category: HarmCategory.HARM_CATEGORY_HARASSMENT, threshold: HarmBlockThreshold.BLOCK_NONE },
{ category: HarmCategory.HARM_CATEGORY_HATE_SPEECH, threshold: HarmBlockThreshold.BLOCK_NONE },
{ category: HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT, threshold: HarmBlockThreshold.BLOCK_NONE },
{ category: HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT, threshold: HarmBlockThreshold.BLOCK_NONE },
] as any,
});
const startApi = performance.now();
const result = await model.generateContent([
{ inlineData: { data: base64Data, mimeType } },
{ text: VISION_PROMPT },
]);
const endApi = performance.now();
return {
data: JSON.parse(result.response.text()),
apiTime: endApi - startApi,
};
}
/**
* Analyze a whisky bottle label image using configured AI provider
*
* Provider is controlled by AI_PROVIDER env variable:
* - "openrouter" (default) - Uses OpenRouter with Gemma 3 27B
* - "gemini" - Uses Google Gemini 2.5 Flash
* *
* @param imageBase64 - Base64 encoded image (with data URL prefix) * @param imageBase64 - Base64 encoded image (with data URL prefix)
* @returns GeminiVisionResult with extracted metadata * @returns GeminiVisionResult with extracted metadata
*/ */
export async function analyzeLabelWithGemini(imageBase64: string): Promise<GeminiVisionResult> { export async function analyzeLabelWithGemini(imageBase64: string): Promise<GeminiVisionResult> {
const startTotal = performance.now(); const startTotal = performance.now();
const provider = getAIProvider();
if (!process.env.GEMINI_API_KEY) { // Check API key based on provider
if (provider === 'gemini' && !process.env.GEMINI_API_KEY) {
return { success: false, error: 'GEMINI_API_KEY is not configured.' }; return { success: false, error: 'GEMINI_API_KEY is not configured.' };
} }
if (provider === 'openrouter' && !process.env.OPENROUTER_API_KEY) {
return { success: false, error: 'OPENROUTER_API_KEY is not configured.' };
}
if (!imageBase64 || imageBase64.length < 100) { if (!imageBase64 || imageBase64.length < 100) {
return { success: false, error: 'Invalid image data provided.' }; return { success: false, error: 'Invalid image data provided.' };
@@ -64,7 +185,7 @@ export async function analyzeLabelWithGemini(imageBase64: string): Promise<Gemin
return { success: false, error: 'Not authorized.' }; return { success: false, error: 'Not authorized.' };
} }
// Credit check // Credit check (use same type for both providers)
const creditCheck = await checkCreditBalance(user.id, 'gemini_ai'); const creditCheck = await checkCreditBalance(user.id, 'gemini_ai');
if (!creditCheck.allowed) { if (!creditCheck.allowed) {
return { return {
@@ -85,72 +206,40 @@ export async function analyzeLabelWithGemini(imageBase64: string): Promise<Gemin
} }
} }
// Initialize Gemini // Call appropriate provider
const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY); console.log(`[Vision] Using provider: ${provider}`);
const model = genAI.getGenerativeModel({ let result: { data: any; apiTime: number };
model: 'gemini-2.5-flash',
generationConfig: {
responseMimeType: "application/json",
responseSchema: visionSchema as any,
temperature: 0.1,
},
safetySettings: [
{ category: HarmCategory.HARM_CATEGORY_HARASSMENT, threshold: HarmBlockThreshold.BLOCK_NONE },
{ category: HarmCategory.HARM_CATEGORY_HATE_SPEECH, threshold: HarmBlockThreshold.BLOCK_NONE },
{ category: HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT, threshold: HarmBlockThreshold.BLOCK_NONE },
{ category: HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT, threshold: HarmBlockThreshold.BLOCK_NONE },
] as any,
});
// Vision prompt if (provider === 'openrouter') {
const prompt = `Analyze this whisky bottle label image and extract all visible metadata. result = await analyzeWithOpenRouter(base64Data, mimeType);
Look carefully for: } else {
- Brand/Distillery name result = await analyzeWithGemini(base64Data, mimeType);
- Bottle name or expression }
- Age statement (e.g., "12 Years Old")
- ABV/Alcohol percentage
- Vintage year (if shown)
- Cask type (e.g., Sherry, Bourbon cask)
- Bottler name (if independent bottling)
- Category (Single Malt, Blended Malt, Bourbon, etc.)
Be precise and only include information you can clearly read from the label.
If you cannot read something clearly, leave it null.`;
// API call with timing
const startApi = performance.now();
const result = await model.generateContent([
{ inlineData: { data: base64Data, mimeType } },
{ text: prompt },
]);
const endApi = performance.now();
// Parse response
const jsonData = JSON.parse(result.response.text());
// Validate with Zod schema // Validate with Zod schema
const validatedData = BottleMetadataSchema.parse(jsonData); const validatedData = BottleMetadataSchema.parse(result.data);
// Track usage and deduct credits // Track usage and deduct credits
await trackApiUsage({ await trackApiUsage({
userId: user.id, userId: user.id,
apiType: 'gemini_ai', apiType: 'gemini_ai', // Keep same type for tracking
endpoint: 'analyzeLabelWithGemini', endpoint: `analyzeLabelWith${provider === 'openrouter' ? 'OpenRouter' : 'Gemini'}`,
success: true success: true
}); });
await deductCredits(user.id, 'gemini_ai', 'Vision label analysis'); await deductCredits(user.id, 'gemini_ai', `Vision label analysis (${provider})`);
return { return {
success: true, success: true,
data: validatedData, data: validatedData,
provider,
perf: { perf: {
apiCall: endApi - startApi, apiCall: result.apiTime,
total: performance.now() - startTotal, total: performance.now() - startTotal,
} }
}; };
} catch (error: any) { } catch (error: any) {
console.error('[GeminiVision] Analysis failed:', error); console.error(`[Vision] Analysis failed (${provider}):`, error);
// Try to track the failure // Try to track the failure
try { try {
@@ -160,18 +249,19 @@ If you cannot read something clearly, leave it null.`;
await trackApiUsage({ await trackApiUsage({
userId: user.id, userId: user.id,
apiType: 'gemini_ai', apiType: 'gemini_ai',
endpoint: 'analyzeLabelWithGemini', endpoint: `analyzeLabelWith${provider === 'openrouter' ? 'OpenRouter' : 'Gemini'}`,
success: false, success: false,
errorMessage: error.message errorMessage: error.message
}); });
} }
} catch (trackError) { } catch (trackError) {
console.warn('[GeminiVision] Failed to track error:', trackError); console.warn('[Vision] Failed to track error:', trackError);
} }
return { return {
success: false, success: false,
error: error.message || 'Vision analysis failed.' error: error.message || 'Vision analysis failed.',
provider,
}; };
} }
} }

40
src/lib/openrouter.ts Normal file
View File

@@ -0,0 +1,40 @@
import OpenAI from 'openai';
/**
* AI Provider configuration
*
* Set AI_PROVIDER in .env.local to switch:
* - "openrouter" (default) - Uses OpenRouter with Gemma 3 27B
* - "gemini" - Uses Google Gemini 2.5 Flash
*/
export type AIProvider = 'openrouter' | 'gemini';
export function getAIProvider(): AIProvider {
const provider = process.env.AI_PROVIDER?.toLowerCase();
if (provider === 'gemini') return 'gemini';
return 'openrouter'; // Default
}
/**
* OpenRouter client for vision tasks
* Uses OpenAI-compatible API
*/
export function getOpenRouterClient(): OpenAI {
const apiKey = process.env.OPENROUTER_API_KEY;
if (!apiKey) {
throw new Error('OPENROUTER_API_KEY is not configured.');
}
return new OpenAI({
baseURL: 'https://openrouter.ai/api/v1',
apiKey: apiKey,
defaultHeaders: {
'HTTP-Referer': 'https://whiskyvault.app',
'X-Title': 'WhiskyVault',
},
});
}
// Default OpenRouter model for vision tasks
export const OPENROUTER_VISION_MODEL = 'google/gemma-3-27b-it';

File diff suppressed because one or more lines are too long