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 { 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
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