toDataUri($filePath); if ($dataUri === null) { return []; } try { $response = Http::withToken($apiKey) ->timeout(45) ->post(self::ENDPOINT, [ 'model' => self::MODEL, 'response_format' => ['type' => 'json_object'], 'messages' => [[ 'role' => 'user', 'content' => [ ['type' => 'text', 'text' => self::PROMPT], ['type' => 'image_url', 'image_url' => $dataUri], ], ]], ]); if ($response->failed()) { Log::warning('Mistral OCR request failed.', ['status' => $response->status()]); return []; } $content = $response->json('choices.0.message.content'); return $this->parseContent(is_string($content) ? $content : ''); } catch (Throwable $e) { Log::warning('Mistral OCR analysis threw an exception.', ['message' => $e->getMessage()]); return []; } } private function toDataUri(string $filePath): ?string { $mime = mime_content_type($filePath) ?: 'image/jpeg'; // Pixtral is a vision model and only accepts images, not PDFs. if (! str_starts_with($mime, 'image/')) { return null; } return 'data:'.$mime.';base64,'.base64_encode((string) file_get_contents($filePath)); } /** * @return array{title?: string|null, expires_at?: string|null} */ private function parseContent(string $content): array { $decoded = json_decode($content, true); if (! is_array($decoded)) { return []; } $title = data_get($decoded, 'title'); $expiresAt = data_get($decoded, 'expires_at'); return array_filter([ 'title' => filled($title) && is_string($title) ? trim($title) : null, 'expires_at' => $this->normalizeDate($expiresAt), ], fn ($value) => $value !== null); } private function normalizeDate(mixed $value): ?string { if (! filled($value) || ! is_string($value)) { return null; } try { return CarbonImmutable::parse($value)->toDateString(); } catch (Throwable) { return null; } } }