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:
@@ -5,6 +5,7 @@ import { BottleMetadataSchema, BottleMetadata } from '@/types/whisky';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { trackApiUsage } from '@/services/track-api-usage';
|
||||
import { checkCreditBalance, deductCredits } from '@/services/credit-service';
|
||||
import { getAIProvider, getOpenRouterClient, OPENROUTER_VISION_MODEL } from '@/lib/openrouter';
|
||||
|
||||
// Schema for Gemini Vision extraction
|
||||
const visionSchema = {
|
||||
@@ -32,24 +33,144 @@ export interface GeminiVisionResult {
|
||||
success: boolean;
|
||||
data?: BottleMetadata;
|
||||
error?: string;
|
||||
provider?: 'gemini' | 'openrouter';
|
||||
perf?: {
|
||||
apiCall: 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)
|
||||
* @returns GeminiVisionResult with extracted metadata
|
||||
*/
|
||||
export async function analyzeLabelWithGemini(imageBase64: string): Promise<GeminiVisionResult> {
|
||||
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.' };
|
||||
}
|
||||
if (provider === 'openrouter' && !process.env.OPENROUTER_API_KEY) {
|
||||
return { success: false, error: 'OPENROUTER_API_KEY is not configured.' };
|
||||
}
|
||||
|
||||
if (!imageBase64 || imageBase64.length < 100) {
|
||||
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.' };
|
||||
}
|
||||
|
||||
// Credit check
|
||||
// Credit check (use same type for both providers)
|
||||
const creditCheck = await checkCreditBalance(user.id, 'gemini_ai');
|
||||
if (!creditCheck.allowed) {
|
||||
return {
|
||||
@@ -85,72 +206,40 @@ export async function analyzeLabelWithGemini(imageBase64: string): Promise<Gemin
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize Gemini
|
||||
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,
|
||||
});
|
||||
// Call appropriate provider
|
||||
console.log(`[Vision] Using provider: ${provider}`);
|
||||
let result: { data: any; apiTime: number };
|
||||
|
||||
// Vision prompt
|
||||
const 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.`;
|
||||
|
||||
// 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());
|
||||
if (provider === 'openrouter') {
|
||||
result = await analyzeWithOpenRouter(base64Data, mimeType);
|
||||
} else {
|
||||
result = await analyzeWithGemini(base64Data, mimeType);
|
||||
}
|
||||
|
||||
// Validate with Zod schema
|
||||
const validatedData = BottleMetadataSchema.parse(jsonData);
|
||||
const validatedData = BottleMetadataSchema.parse(result.data);
|
||||
|
||||
// Track usage and deduct credits
|
||||
await trackApiUsage({
|
||||
userId: user.id,
|
||||
apiType: 'gemini_ai',
|
||||
endpoint: 'analyzeLabelWithGemini',
|
||||
apiType: 'gemini_ai', // Keep same type for tracking
|
||||
endpoint: `analyzeLabelWith${provider === 'openrouter' ? 'OpenRouter' : 'Gemini'}`,
|
||||
success: true
|
||||
});
|
||||
await deductCredits(user.id, 'gemini_ai', 'Vision label analysis');
|
||||
await deductCredits(user.id, 'gemini_ai', `Vision label analysis (${provider})`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: validatedData,
|
||||
provider,
|
||||
perf: {
|
||||
apiCall: endApi - startApi,
|
||||
apiCall: result.apiTime,
|
||||
total: performance.now() - startTotal,
|
||||
}
|
||||
};
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('[GeminiVision] Analysis failed:', error);
|
||||
console.error(`[Vision] Analysis failed (${provider}):`, error);
|
||||
|
||||
// Try to track the failure
|
||||
try {
|
||||
@@ -160,18 +249,19 @@ If you cannot read something clearly, leave it null.`;
|
||||
await trackApiUsage({
|
||||
userId: user.id,
|
||||
apiType: 'gemini_ai',
|
||||
endpoint: 'analyzeLabelWithGemini',
|
||||
endpoint: `analyzeLabelWith${provider === 'openrouter' ? 'OpenRouter' : 'Gemini'}`,
|
||||
success: false,
|
||||
errorMessage: error.message
|
||||
});
|
||||
}
|
||||
} catch (trackError) {
|
||||
console.warn('[GeminiVision] Failed to track error:', trackError);
|
||||
console.warn('[Vision] Failed to track error:', trackError);
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: error.message || 'Vision analysis failed.'
|
||||
error: error.message || 'Vision analysis failed.',
|
||||
provider,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
40
src/lib/openrouter.ts
Normal file
40
src/lib/openrouter.ts
Normal 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
Reference in New Issue
Block a user