[ [ "parts" => [ ["text" => $prompt . " If the image is not an invoice, is blank, or is completely unreadable, return ONLY: {\"error\": \"invalid_invoice\"}. DO NOT guess or invent data."], [ "inline_data" => [ "mime_type" => $mimeType, "data" => $base64Data ] ] ] ] ], "generationConfig" => [ "response_mime_type" => "application/json" ] ]; // Retry with exponential backoff for 503/429 errors for ($attempt = 1; $attempt <= self::$maxRetries; $attempt++) { $ch = curl_init(self::$baseUrl . "?key=" . $apiKey); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload)); curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']); curl_setopt($ch, CURLOPT_TIMEOUT, 60); $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $curlError = curl_error($ch); curl_close($ch); if ($curlError) { error_log("AI Error: cURL failed (attempt $attempt): $curlError"); if ($attempt < self::$maxRetries) { $wait = pow(2, $attempt) + rand(1, 3); echo " Retrying in {$wait}s (cURL error)...\n"; sleep($wait); continue; } return null; } if ($httpCode === 200) { break; // Success } // Retry on 503 (overloaded) or 429 (rate limit) if (in_array($httpCode, [503, 429]) && $attempt < self::$maxRetries) { $wait = pow(2, $attempt) + rand(1, 3); echo " Gemini $httpCode — retrying in {$wait}s (attempt $attempt/" . self::$maxRetries . ")...\n"; sleep($wait); continue; } error_log("AI Error: Gemini API returned code $httpCode. Response: " . $response); return null; } if ($httpCode !== 200) { error_log("AI Error: All retries exhausted. Last code: $httpCode"); return null; } $result = json_decode($response, true); $textResponse = $result['candidates'][0]['content']['parts'][0]['text'] ?? null; if (!$textResponse) return null; $data = json_decode($textResponse, true); if (isset($data['error']) && $data['error'] === 'invalid_invoice') { return null; } // Track token usage from Gemini response $usage = $result['usageMetadata'] ?? []; if (!empty($usage)) { self::logTokenUsage($usage); } return $data; } /** * Log AI token usage to the database for cost tracking */ private static function logTokenUsage(array $usage): void { try { $db = Database::getInstance(); $inputTokens = (int)($usage['promptTokenCount'] ?? 0); $outputTokens = (int)($usage['candidatesTokenCount'] ?? 0); $totalTokens = (int)($usage['totalTokenCount'] ?? ($inputTokens + $outputTokens)); // Gemini Flash Lite pricing: $0.075/1M input, $0.30/1M output $inputCost = ($inputTokens / 1000000) * 0.075; $outputCost = ($outputTokens / 1000000) * 0.30; $totalCostUsd = $inputCost + $outputCost; $totalCostJod = $totalCostUsd * 0.709; // 1 USD ≈ 0.709 JOD $db->prepare(" INSERT INTO ai_usage_log (id, input_tokens, output_tokens, total_tokens, cost_usd, cost_jod, model, created_at) VALUES (UUID(), ?, ?, ?, ?, ?, 'gemini-flash-lite', NOW()) ")->execute([ $inputTokens, $outputTokens, $totalTokens, round($totalCostUsd, 8), round($totalCostJod, 8), ]); } catch (\Exception $e) { // Never crash the main flow for logging error_log("[AI] Token usage log failed: " . $e->getMessage()); } } /** * Get aggregated AI usage stats */ public static function getUsageStats(?string $tenantId = null): array { try { $db = Database::getInstance(); $stmt = $db->query(" SELECT COUNT(*) as total_requests, COALESCE(SUM(input_tokens), 0) as total_input_tokens, COALESCE(SUM(output_tokens), 0) as total_output_tokens, COALESCE(SUM(total_tokens), 0) as total_tokens, COALESCE(SUM(cost_usd), 0) as total_cost_usd, COALESCE(SUM(cost_jod), 0) as total_cost_jod, COALESCE(AVG(total_tokens), 0) as avg_tokens_per_request, COALESCE(AVG(cost_jod), 0) as avg_cost_jod_per_request FROM ai_usage_log "); return $stmt->fetch() ?: []; } catch (\Exception $e) { return []; } } }