$texts * @param array $images */ public function ensureAcceptable(array $texts = [], array $images = []): void { $normalizedTexts = collect($texts) ->flatten() ->filter(fn (mixed $text): bool => is_string($text) && filled(trim($text))) ->map(fn (string $text): string => trim($text)) ->values() ->all(); $normalizedImages = collect($images) ->filter(fn (mixed $image): bool => $image instanceof UploadedFile || $this->isRemoteImageUrl($image)) ->values() ->all(); if ($normalizedTexts === [] && $normalizedImages === []) { return; } if ($this->containsExplicitlyBlockedText($normalizedTexts)) { $this->rejectContent(); } if (! config('moderation.content.enabled')) { return; } $apiKey = trim((string) config('moderation.content.openai_key')); if ($apiKey === '') { throw new HttpException(503, 'CONTENT_MODERATION_UNAVAILABLE'); } $input = []; if ($normalizedTexts !== []) { $input[] = [ 'type' => 'text', 'text' => implode("\n", $normalizedTexts), ]; } foreach ($normalizedImages as $image) { $input[] = [ 'type' => 'image_url', 'image_url' => [ 'url' => $image instanceof UploadedFile ? $this->uploadedFileDataUrl($image) : $image, ], ]; } try { $response = Http::baseUrl(rtrim((string) config('moderation.content.openai_url'), '/')) ->withToken($apiKey) ->acceptJson() ->timeout((int) config('moderation.content.timeout', 12)) ->retry(2, 250) ->post('/moderations', [ 'model' => config('moderation.content.model'), 'input' => $input, ]) ->throw(); } catch (Throwable $exception) { report($exception); throw new HttpException(503, 'CONTENT_MODERATION_UNAVAILABLE', $exception); } if (collect($response->json('results', []))->contains( fn (mixed $result): bool => is_array($result) && ($result['flagged'] ?? false) === true )) { $this->rejectContent(); } } /** * @param array $texts */ private function containsExplicitlyBlockedText(array $texts): bool { $content = Str::lower(Str::ascii(implode("\n", $texts))); return collect(config('moderation.content.blocked_phrases', [])) ->filter(fn (mixed $phrase): bool => is_string($phrase) && filled($phrase)) ->contains(fn (string $phrase): bool => Str::contains($content, Str::lower(Str::ascii($phrase)))); } private function uploadedFileDataUrl(UploadedFile $image): string { $contents = file_get_contents($image->getRealPath()); if ($contents === false) { throw new HttpException(503, 'CONTENT_MODERATION_UNAVAILABLE'); } return sprintf( 'data:%s;base64,%s', $image->getMimeType() ?: 'application/octet-stream', base64_encode($contents), ); } private function isRemoteImageUrl(mixed $image): bool { return is_string($image) && Str::startsWith($image, ['https://', 'http://']); } private function rejectContent(): never { throw ValidationException::withMessages([ 'content' => ['CONTENT_MODERATION_REJECTED'], ]); } }