refactor: Consolidate all AI calls to use OpenRouter provider switch
- bulk-scan.ts: Now uses OpenRouter for batch analysis - scan-label.ts: Now uses OpenRouter with Gemini fallback - analyze-bottle.ts: Now uses OpenRouter with Gemini fallback - All AI calls now respect AI_PROVIDER env variable - Uses Nebius/FP8 provider preferences consistently - Unified logging: [FunctionName] Using provider: openrouter/gemini
This commit is contained in:
@@ -6,6 +6,7 @@ import { createClient } from '@/lib/supabase/server';
|
||||
import { createHash } from 'crypto';
|
||||
import { trackApiUsage } from '@/services/track-api-usage';
|
||||
import { checkCreditBalance, deductCredits } from '@/services/credit-service';
|
||||
import { getAIProvider, getOpenRouterClient, OPENROUTER_PROVIDER_PREFERENCES } from '@/lib/openrouter';
|
||||
import sharp from 'sharp';
|
||||
|
||||
// Native Schema Definition for Gemini API
|
||||
@@ -30,10 +31,30 @@ const metadataSchema = {
|
||||
required: ["name", "is_whisky", "confidence"],
|
||||
};
|
||||
|
||||
const SCAN_PROMPT = `Extract whisky label metadata. Return JSON with:
|
||||
- name: Full product name
|
||||
- distillery: Distillery name
|
||||
- bottler: Independent bottler if applicable
|
||||
- category: e.g. "Single Malt", "Bourbon"
|
||||
- abv: Alcohol percentage
|
||||
- age: Age statement in years
|
||||
- vintage: Vintage year
|
||||
- distilled_at: Distillation date
|
||||
- bottled_at: Bottling date
|
||||
- batch_info: Batch or cask info
|
||||
- is_whisky: boolean
|
||||
- confidence: 0-1`;
|
||||
|
||||
export async function scanLabel(input: any): Promise<AnalysisResponse> {
|
||||
if (!process.env.GEMINI_API_KEY) {
|
||||
const provider = getAIProvider();
|
||||
|
||||
// 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.' };
|
||||
}
|
||||
|
||||
let supabase;
|
||||
try {
|
||||
@@ -101,7 +122,7 @@ export async function scanLabel(input: any): Promise<AnalysisResponse> {
|
||||
};
|
||||
}
|
||||
|
||||
// Step 3: AI-Specific Image Optimization (Grayscale, Normalize, Resize)
|
||||
// Step 3: AI-Specific Image Optimization
|
||||
const startOptimization = performance.now();
|
||||
const optimizedBuffer = await sharp(buffer)
|
||||
.resize(1024, 1024, { fit: 'inside', withoutEnlargement: true })
|
||||
@@ -117,94 +138,155 @@ export async function scanLabel(input: any): Promise<AnalysisResponse> {
|
||||
const uploadSize = optimizedBuffer.length;
|
||||
const endEncoding = performance.now();
|
||||
|
||||
// Step 5: Model Initialization & Step 6: API Call
|
||||
// Step 5: AI Analysis
|
||||
const startAiTotal = performance.now();
|
||||
let jsonData;
|
||||
let validatedData;
|
||||
|
||||
try {
|
||||
const startModelInit = performance.now();
|
||||
const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY);
|
||||
const model = genAI.getGenerativeModel({
|
||||
model: 'gemini-2.5-flash',
|
||||
generationConfig: {
|
||||
responseMimeType: "application/json",
|
||||
responseSchema: metadataSchema as any,
|
||||
console.log(`[ScanLabel] Using provider: ${provider}`);
|
||||
|
||||
if (provider === 'openrouter') {
|
||||
// OpenRouter path
|
||||
const client = getOpenRouterClient();
|
||||
const startApi = performance.now();
|
||||
|
||||
const response = await client.chat.completions.create({
|
||||
model: 'google/gemma-3-27b-it',
|
||||
messages: [{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'image_url', image_url: { url: `data:${mimeType};base64,${base64Data}` } },
|
||||
{ type: 'text', text: SCAN_PROMPT + '\n\nRespond ONLY with valid JSON.' },
|
||||
],
|
||||
}],
|
||||
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 endModelInit = performance.now();
|
||||
max_tokens: 1024,
|
||||
// @ts-ignore
|
||||
provider: OPENROUTER_PROVIDER_PREFERENCES,
|
||||
});
|
||||
|
||||
const instruction = "Extract whisky label metadata.";
|
||||
const startApi = performance.now();
|
||||
const result = await model.generateContent([
|
||||
{ inlineData: { data: base64Data, mimeType: mimeType } },
|
||||
{ text: instruction },
|
||||
]);
|
||||
const endApi = performance.now();
|
||||
const endApi = performance.now();
|
||||
const content = response.choices[0]?.message?.content || '{}';
|
||||
|
||||
const startParse = performance.now();
|
||||
jsonData = JSON.parse(result.response.text());
|
||||
const endParse = performance.now();
|
||||
const startParse = performance.now();
|
||||
let jsonStr = content;
|
||||
const jsonMatch = content.match(/```(?:json)?\s*([\s\S]*?)```/);
|
||||
if (jsonMatch) jsonStr = jsonMatch[1].trim();
|
||||
jsonData = JSON.parse(jsonStr);
|
||||
const endParse = performance.now();
|
||||
|
||||
const startValidation = performance.now();
|
||||
validatedData = BottleMetadataSchema.parse(jsonData);
|
||||
const endValidation = performance.now();
|
||||
const startValidation = performance.now();
|
||||
validatedData = BottleMetadataSchema.parse(jsonData);
|
||||
const endValidation = performance.now();
|
||||
|
||||
// Cache record
|
||||
await supabase.from('vision_cache').insert({ hash: imageHash, result: validatedData });
|
||||
await supabase.from('vision_cache').insert({ hash: imageHash, result: validatedData });
|
||||
|
||||
await trackApiUsage({
|
||||
userId: userId,
|
||||
apiType: 'gemini_ai',
|
||||
endpoint: 'scanLabel',
|
||||
success: true
|
||||
});
|
||||
await deductCredits(userId, 'gemini_ai', 'Bottle OCR scan');
|
||||
await trackApiUsage({
|
||||
userId: userId,
|
||||
apiType: 'gemini_ai',
|
||||
endpoint: 'scanLabel_openrouter',
|
||||
success: true
|
||||
});
|
||||
await deductCredits(userId, 'gemini_ai', 'Bottle OCR scan (OpenRouter)');
|
||||
|
||||
const totalTime = performance.now() - perfTotal;
|
||||
return {
|
||||
success: true,
|
||||
data: validatedData,
|
||||
perf: {
|
||||
imagePrep: endImagePrep - startImagePrep,
|
||||
optimization: endOptimization - startOptimization,
|
||||
cacheCheck: endCacheCheck - startCacheCheck,
|
||||
encoding: endEncoding - startEncoding,
|
||||
modelInit: endModelInit - startModelInit,
|
||||
apiCall: endApi - startApi,
|
||||
parsing: endParse - startParse,
|
||||
validation: endValidation - startValidation,
|
||||
total: totalTime,
|
||||
cacheHit: false
|
||||
},
|
||||
raw: jsonData
|
||||
} as any;
|
||||
return {
|
||||
success: true,
|
||||
data: validatedData,
|
||||
perf: {
|
||||
imagePrep: endImagePrep - startImagePrep,
|
||||
optimization: endOptimization - startOptimization,
|
||||
cacheCheck: endCacheCheck - startCacheCheck,
|
||||
encoding: endEncoding - startEncoding,
|
||||
apiCall: endApi - startApi,
|
||||
parsing: endParse - startParse,
|
||||
validation: endValidation - startValidation,
|
||||
total: performance.now() - perfTotal,
|
||||
cacheHit: false
|
||||
},
|
||||
raw: jsonData
|
||||
} as any;
|
||||
|
||||
} else {
|
||||
// Gemini path
|
||||
const startModelInit = performance.now();
|
||||
const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY!);
|
||||
const model = genAI.getGenerativeModel({
|
||||
model: 'gemini-2.5-flash',
|
||||
generationConfig: {
|
||||
responseMimeType: "application/json",
|
||||
responseSchema: metadataSchema 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 endModelInit = performance.now();
|
||||
|
||||
const startApi = performance.now();
|
||||
const result = await model.generateContent([
|
||||
{ inlineData: { data: base64Data, mimeType: mimeType } },
|
||||
{ text: 'Extract whisky label metadata.' },
|
||||
]);
|
||||
const endApi = performance.now();
|
||||
|
||||
const startParse = performance.now();
|
||||
jsonData = JSON.parse(result.response.text());
|
||||
const endParse = performance.now();
|
||||
|
||||
const startValidation = performance.now();
|
||||
validatedData = BottleMetadataSchema.parse(jsonData);
|
||||
const endValidation = performance.now();
|
||||
|
||||
await supabase.from('vision_cache').insert({ hash: imageHash, result: validatedData });
|
||||
|
||||
await trackApiUsage({
|
||||
userId: userId,
|
||||
apiType: 'gemini_ai',
|
||||
endpoint: 'scanLabel_gemini',
|
||||
success: true
|
||||
});
|
||||
await deductCredits(userId, 'gemini_ai', 'Bottle OCR scan (Gemini)');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: validatedData,
|
||||
perf: {
|
||||
imagePrep: endImagePrep - startImagePrep,
|
||||
optimization: endOptimization - startOptimization,
|
||||
cacheCheck: endCacheCheck - startCacheCheck,
|
||||
encoding: endEncoding - startEncoding,
|
||||
modelInit: endModelInit - startModelInit,
|
||||
apiCall: endApi - startApi,
|
||||
parsing: endParse - startParse,
|
||||
validation: endValidation - startValidation,
|
||||
total: performance.now() - perfTotal,
|
||||
cacheHit: false
|
||||
},
|
||||
raw: jsonData
|
||||
} as any;
|
||||
}
|
||||
|
||||
} catch (aiError: any) {
|
||||
console.warn('[ScanLabel] AI Analysis failed, providing fallback path:', aiError.message);
|
||||
console.warn(`[ScanLabel] ${provider} failed:`, aiError.message);
|
||||
|
||||
// Track failure
|
||||
await trackApiUsage({
|
||||
userId: userId,
|
||||
apiType: 'gemini_ai',
|
||||
endpoint: 'scanLabel',
|
||||
endpoint: `scanLabel_${provider}`,
|
||||
success: false,
|
||||
errorMessage: aiError.message
|
||||
});
|
||||
|
||||
// Return a specific structure that ScanAndTasteFlow can use to fallback to placeholder
|
||||
return {
|
||||
success: false,
|
||||
isAiError: true,
|
||||
error: aiError.message,
|
||||
imageHash: imageHash // Useful for local tracking
|
||||
imageHash: imageHash
|
||||
} as any;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user