HEX
Server: LiteSpeed
System: Linux s1049.use1.mysecurecloudhost.com 4.18.0-477.27.2.lve.el8.x86_64 #1 SMP Wed Oct 11 12:32:56 UTC 2023 x86_64
User: xedaptot (3356)
PHP: 8.3.31
Disabled: NONE
Upload Files
File: /home/xedaptot/hi.naniguide.com/app/Services/AI/OpenAIClient.php
<?php

namespace App\Services\AI;

use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;

class OpenAIClient
{
    private function removeTrailingCommas(string $json): string
    {
        $out = $json;
        // Common model mistake: trailing commas before closing braces/brackets.
        $out = preg_replace('/,\s*}/', '}', $out) ?? $out;
        $out = preg_replace('/,\s*]/', ']', $out) ?? $out;
        return $out;
    }

    private function extractJsonObject(string $text): ?string
    {
        $text = trim($text);
        if ($text === '') {
            return null;
        }

        if (str_starts_with($text, '{') && str_ends_with($text, '}')) {
            return $text;
        }

        $start = strpos($text, '{');
        $end = strrpos($text, '}');
        if ($start === false || $end === false || $end <= $start) {
            return null;
        }

        return substr($text, $start, ($end - $start) + 1);
    }

    private function decodeJsonObject(string $text): ?array
    {
        $text = trim($text);
        if ($text === '') {
            return null;
        }

        $decoded = json_decode($text, true);
        if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
            return $decoded;
        }

        $fixed = $this->removeTrailingCommas($text);
        $decoded = json_decode($fixed, true);
        if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
            return $decoded;
        }

        $extracted = $this->extractJsonObject($text);
        if (!is_string($extracted) || trim($extracted) === '') {
            return null;
        }

        $decoded = json_decode($extracted, true);
        if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
            return $decoded;
        }

        $extractedFixed = $this->removeTrailingCommas($extracted);
        $decoded = json_decode($extractedFixed, true);
        if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
            return $decoded;
        }

        return null;
    }

    private function extractStringValue(mixed $value): ?string
    {
        if (is_string($value)) {
            $value = trim($value);
            return $value !== '' ? $value : null;
        }

        if (!is_array($value)) {
            return null;
        }

        foreach (['value', 'text', 'output_text', 'content'] as $k) {
            if (is_string($value[$k] ?? null)) {
                $v = trim((string) $value[$k]);
                return $v !== '' ? $v : null;
            }
        }

        return null;
    }

    private function extractChoiceText(array $choice): array
    {
        $message = is_array($choice['message'] ?? null) ? $choice['message'] : [];
        $refusal = is_string($message['refusal'] ?? null) ? trim((string) $message['refusal']) : null;

        $content = $message['content'] ?? null;
        if (is_string($content)) {
            $text = trim($content);
            return ['text' => $text !== '' ? $text : null, 'refusal' => $refusal];
        }

        // Some models may return an array of content parts.
        if (is_array($content)) {
            $parts = [];
            foreach ($content as $part) {
                if (!is_array($part)) {
                    continue;
                }

                $type = is_string($part['type'] ?? null) ? $part['type'] : null;
                if ($type === 'text' || $type === 'output_text' || $type === null) {
                    foreach (['text', 'output_text', 'content', 'value'] as $k) {
                        $maybe = $this->extractStringValue($part[$k] ?? null);
                        if ($maybe !== null) {
                            $parts[] = $maybe;
                            continue 2;
                        }
                    }
                }

                // Some payloads may omit type.
                if ($type === null && is_string($part['text'] ?? null)) {
                    $parts[] = $part['text'];
                }
            }

            $text = trim(implode("\n", $parts));
            return ['text' => $text !== '' ? $text : null, 'refusal' => $refusal];
        }

        return ['text' => null, 'refusal' => $refusal];
    }

    private function extractTotalTokens(array $data): ?int
    {
        $usage = is_array($data['usage'] ?? null) ? $data['usage'] : null;
        if (!is_array($usage)) {
            return null;
        }

        if (is_numeric($usage['total_tokens'] ?? null)) {
            return (int) $usage['total_tokens'];
        }

        $prompt = is_numeric($usage['prompt_tokens'] ?? null) ? (int) $usage['prompt_tokens'] : null;
        $completion = is_numeric($usage['completion_tokens'] ?? null) ? (int) $usage['completion_tokens'] : null;

        if ($prompt !== null || $completion !== null) {
            return (int) (($prompt ?? 0) + ($completion ?? 0));
        }

        return null;
    }

    private function usesMaxCompletionTokens(string $model): bool
    {
        $model = strtolower(trim($model));
        if ($model === '') {
            return false;
        }

        return str_starts_with($model, 'gpt-5')
            || str_starts_with($model, 'o1')
            || str_starts_with($model, 'o3');
    }

    private function supportsReasoningEffort(string $model): bool
    {
        $model = strtolower(trim($model));
        if ($model === '') {
            return false;
        }

        return str_starts_with($model, 'gpt-5')
            || str_starts_with($model, 'o1')
            || str_starts_with($model, 'o3');
    }

    private function shouldOmitTemperature(string $model): bool
    {
        $model = strtolower(trim($model));
        if ($model === '') {
            return false;
        }

        return str_starts_with($model, 'gpt-5')
            || str_starts_with($model, 'o1')
            || str_starts_with($model, 'o3');
    }

    public function generateTemplateHtml(string $apiKey, string $prompt, int $maxTokens = 1200, ?string $model = null): array
    {
        $prompt = trim($prompt);
        $model = is_string($model) && trim($model) !== '' ? trim($model) : 'gpt-5-mini';

        $payload = [
            'model' => $model,
            'messages' => [
                [
                    'role' => 'system',
                    'content' => 'You generate email template HTML. Output ONLY valid HTML (no markdown, no code fences). Do NOT use inline base64 images (data:image/...). Use only publicly accessible image URLs.',
                ],
                [
                    'role' => 'user',
                    'content' => $prompt,
                ],
            ],
        ];

        if (!$this->shouldOmitTemperature($model)) {
            $payload['temperature'] = 0.7;
        }

        if ($this->usesMaxCompletionTokens($model)) {
            $payload['max_completion_tokens'] = max(1, $maxTokens);
        } else {
            $payload['max_tokens'] = max(1, $maxTokens);
        }

        if ($this->supportsReasoningEffort($model)) {
            $payload['reasoning_effort'] = 'minimal';
        }

        try {
            if (function_exists('set_time_limit')) {
                @set_time_limit(120);
            }

            $response = Http::timeout(60)
                ->retry(1, 200)
                ->withToken($apiKey)
                ->post('https://api.openai.com/v1/chat/completions', $payload);

            if (!$response->successful()) {
                return [
                    'success' => false,
                    'html' => null,
                    'tokens' => null,
                    'message' => 'OpenAI request failed.',
                    'raw' => [
                        'status' => $response->status(),
                        'body' => $response->body(),
                    ],
                ];
            }

            $data = $response->json();
            if (!is_array($data)) {
                return [
                    'success' => false,
                    'html' => null,
                    'tokens' => null,
                    'message' => 'OpenAI returned invalid JSON.',
                    'raw' => ['body' => $response->body()],
                ];
            }

            if (is_array($data['error'] ?? null) && is_string($data['error']['message'] ?? null)) {
                return [
                    'success' => false,
                    'html' => null,
                    'tokens' => null,
                    'message' => (string) $data['error']['message'],
                    'raw' => $data,
                ];
            }

            $choice = is_array($data['choices'][0] ?? null) ? $data['choices'][0] : [];
            $extracted = $this->extractChoiceText($choice);
            $content = is_string($extracted['text'] ?? null) ? (string) $extracted['text'] : null;
            $refusal = is_string($extracted['refusal'] ?? null) ? (string) $extracted['refusal'] : null;

            $finishReason = is_string($choice['finish_reason'] ?? null) ? (string) $choice['finish_reason'] : null;

            if (!is_string($content) || $content === '') {
                Log::warning('OpenAI returned empty content', [
                    'model' => $model,
                    'finish_reason' => $finishReason,
                    'choice' => $choice,
                ]);
                return [
                    'success' => false,
                    'html' => null,
                    'tokens' => null,
                    'message' => $refusal !== null && $refusal !== ''
                        ? ('OpenAI refused the request: ' . $refusal)
                        : (
                            $finishReason === 'length' && $this->supportsReasoningEffort($model)
                                ? 'OpenAI hit the token limit before producing any visible output for this model. Please shorten your prompt, increase the token budget, or use gpt-4.1.'
                                : ('OpenAI returned an empty response.' . ($finishReason ? (' finish_reason=' . $finishReason) : ''))
                        ),
                    'raw' => $data,
                ];
            }

            $original = $content;
            $content = preg_replace('/^```(?:html)?\s*/i', '', $content) ?? $content;
            $content = preg_replace('/\s*```\s*$/', '', $content) ?? $content;
            $content = trim($content);

            if ($content === '') {
                $fallback = trim((string) $original);
                $looksLikeFenceOnly = preg_match('/^```(?:html)?\s*$/i', $fallback) === 1;

                if ($looksLikeFenceOnly) {
                    return [
                        'success' => false,
                        'html' => null,
                        'tokens' => null,
                        'message' => $finishReason === 'length'
                            ? 'OpenAI response was truncated due to token limit. Please shorten your prompt or increase the token budget.'
                            : 'OpenAI returned an empty response.',
                        'raw' => $data,
                    ];
                }

                $content = $fallback;
            }

            $tokens = $this->extractTotalTokens($data);

            return [
                'success' => true,
                'html' => $content,
                'tokens' => $tokens,
                'message' => null,
                'raw' => $data,
            ];
        } catch (\Throwable $e) {
            return [
                'success' => false,
                'html' => null,
                'tokens' => null,
                'message' => $e->getMessage(),
                'raw' => null,
            ];
        }
    }

    public function generateTemplateUnlayerDesign(string $apiKey, string $prompt, int $maxTokens = 1200, ?string $model = null): array
    {
        $prompt = trim($prompt);
        $model = is_string($model) && trim($model) !== '' ? trim($model) : 'gpt-5-mini';

        $payload = [
            'model' => $model,
            'messages' => [
                [
                    'role' => 'system',
                    'content' => 'You generate email templates as Unlayer design JSON. Output ONLY valid JSON (no markdown, no code fences). The JSON must be a design object with keys: counters and body. The body must contain rows/columns/contents with proper Unlayer block types like heading, text, image, button, divider, spacer. Do NOT use inline base64 images (data:image/...). Use only publicly accessible image URLs.',
                ],
                [
                    'role' => 'user',
                    'content' => $prompt,
                ],
            ],
        ];

        if (!$this->shouldOmitTemperature($model)) {
            $payload['temperature'] = 0.4;
        }

        if ($this->usesMaxCompletionTokens($model)) {
            $payload['max_completion_tokens'] = max(1, $maxTokens);
        } else {
            $payload['max_tokens'] = max(1, $maxTokens);
        }

        if ($this->supportsReasoningEffort($model)) {
            $payload['reasoning_effort'] = 'minimal';
        }

        $payloadWithJsonFormat = $payload;
        $payloadWithJsonFormat['response_format'] = ['type' => 'json_object'];

        try {
            if (function_exists('set_time_limit')) {
                @set_time_limit(120);
            }

            $attempts = [$payloadWithJsonFormat, $payload];
            $response = null;
            foreach ($attempts as $i => $p) {
                $response = Http::timeout(60)
                    ->retry(1, 200)
                    ->withToken($apiKey)
                    ->post('https://api.openai.com/v1/chat/completions', $p);

                if ($response->successful()) {
                    break;
                }

                if ($i === 0 && $response->status() === 400) {
                    $maybe = $response->json();
                    $errMessage = is_array($maybe) && is_array($maybe['error'] ?? null) && is_string($maybe['error']['message'] ?? null)
                        ? strtolower((string) $maybe['error']['message'])
                        : '';
                    if ($errMessage !== '' && (str_contains($errMessage, 'response_format') || str_contains($errMessage, 'json') || str_contains($errMessage, 'schema'))) {
                        continue;
                    }
                }
            }

            if (!$response || !$response->successful()) {
                return [
                    'success' => false,
                    'design' => null,
                    'tokens' => null,
                    'message' => 'OpenAI request failed.',
                    'raw' => [
                        'status' => $response ? $response->status() : null,
                        'body' => $response ? $response->body() : null,
                    ],
                ];
            }

            $data = $response->json();
            if (!is_array($data)) {
                return [
                    'success' => false,
                    'design' => null,
                    'tokens' => null,
                    'message' => 'OpenAI returned invalid JSON.',
                    'raw' => ['body' => $response->body()],
                ];
            }

            if (is_array($data['error'] ?? null) && is_string($data['error']['message'] ?? null)) {
                return [
                    'success' => false,
                    'design' => null,
                    'tokens' => null,
                    'message' => (string) $data['error']['message'],
                    'raw' => $data,
                ];
            }

            $choice = is_array($data['choices'][0] ?? null) ? $data['choices'][0] : [];
            $extracted = $this->extractChoiceText($choice);
            $content = is_string($extracted['text'] ?? null) ? (string) $extracted['text'] : null;
            $refusal = is_string($extracted['refusal'] ?? null) ? (string) $extracted['refusal'] : null;

            $finishReason = is_string($choice['finish_reason'] ?? null) ? (string) $choice['finish_reason'] : null;

            if (!is_string($content) || $content === '') {
                return [
                    'success' => false,
                    'design' => null,
                    'tokens' => null,
                    'message' => $refusal !== null && $refusal !== ''
                        ? ('OpenAI refused the request: ' . $refusal)
                        : ('OpenAI returned an empty response.' . ($finishReason ? (' finish_reason=' . $finishReason) : '')),
                    'raw' => $data,
                ];
            }

            $original = $content;
            $content = preg_replace('/^```(?:json)?\s*/i', '', $content) ?? $content;
            $content = preg_replace('/\s*```\s*$/', '', $content) ?? $content;
            $content = trim($content);
            if ($content === '') {
                $content = trim((string) $original);
            }

            $decoded = $this->decodeJsonObject($content);
            if (!is_array($decoded)) {
                $debugExcerpt = null;
                if (config('app.debug') && is_string($content) && $content !== '') {
                    $debugExcerpt = substr(str_replace(["\r", "\n"], [' ', ' '], $content), 0, 900);
                }

                return [
                    'success' => false,
                    'design' => null,
                    'tokens' => $this->extractTotalTokens($data),
                    'message' => $finishReason === 'length'
                        ? 'OpenAI response was truncated due to token limit. Please shorten your prompt or increase the token budget.'
                        : ('OpenAI returned invalid Unlayer design JSON.' . ($debugExcerpt ? (' Output excerpt: ' . $debugExcerpt) : '')),
                    'raw' => $data,
                ];
            }

            $design = $decoded;
            if (!isset($design['body']) && (isset($design['rows']) || isset($design['values']))) {
                $design = [
                    'counters' => [],
                    'body' => $decoded,
                ];
            }

            if (!is_array($design['body'] ?? null)) {
                return [
                    'success' => false,
                    'design' => null,
                    'tokens' => $this->extractTotalTokens($data),
                    'message' => 'OpenAI returned an invalid Unlayer design structure.',
                    'raw' => $data,
                ];
            }

            if (!isset($design['counters']) || (!is_array($design['counters']) && !is_object($design['counters']))) {
                $design['counters'] = [];
            }

            return [
                'success' => true,
                'design' => $design,
                'tokens' => $this->extractTotalTokens($data),
                'message' => null,
                'raw' => $data,
            ];
        } catch (\Throwable $e) {
            return [
                'success' => false,
                'design' => null,
                'tokens' => null,
                'message' => $e->getMessage(),
                'raw' => null,
            ];
        }
    }

    public function generateText(string $apiKey, string $prompt, int $maxTokens = 1200, ?string $model = null): array
    {
        $prompt = trim($prompt);
        $model = is_string($model) && trim($model) !== '' ? trim($model) : 'gpt-5-mini';

        $payload = [
            'model' => $model,
            'messages' => [
                [
                    'role' => 'system',
                    'content' => 'You write email copy. Output ONLY plain text (no markdown, no code fences).',
                ],
                [
                    'role' => 'user',
                    'content' => $prompt,
                ],
            ],
        ];

        if (!$this->shouldOmitTemperature($model)) {
            $payload['temperature'] = 0.7;
        }

        if ($this->usesMaxCompletionTokens($model)) {
            $payload['max_completion_tokens'] = max(1, $maxTokens);
        } else {
            $payload['max_tokens'] = max(1, $maxTokens);
        }

        if ($this->supportsReasoningEffort($model)) {
            $payload['reasoning_effort'] = 'minimal';
        }

        try {
            if (function_exists('set_time_limit')) {
                @set_time_limit(120);
            }

            $response = Http::timeout(60)
                ->retry(1, 200)
                ->withToken($apiKey)
                ->post('https://api.openai.com/v1/chat/completions', $payload);

            if (!$response->successful()) {
                return [
                    'success' => false,
                    'text' => null,
                    'tokens' => null,
                    'message' => 'OpenAI request failed.',
                    'raw' => [
                        'status' => $response->status(),
                        'body' => $response->body(),
                    ],
                ];
            }

            $data = $response->json();
            if (!is_array($data)) {
                return [
                    'success' => false,
                    'text' => null,
                    'tokens' => null,
                    'message' => 'OpenAI returned invalid JSON.',
                    'raw' => ['body' => $response->body()],
                ];
            }

            if (is_array($data['error'] ?? null) && is_string($data['error']['message'] ?? null)) {
                return [
                    'success' => false,
                    'text' => null,
                    'tokens' => null,
                    'message' => (string) $data['error']['message'],
                    'raw' => $data,
                ];
            }

            $choice = is_array($data['choices'][0] ?? null) ? $data['choices'][0] : [];
            $extracted = $this->extractChoiceText($choice);
            $content = is_string($extracted['text'] ?? null) ? (string) $extracted['text'] : null;
            $refusal = is_string($extracted['refusal'] ?? null) ? (string) $extracted['refusal'] : null;

            $finishReason = is_string($choice['finish_reason'] ?? null) ? (string) $choice['finish_reason'] : null;

            if (!is_string($content) || $content === '') {
                Log::warning('OpenAI returned empty content', [
                    'model' => $model,
                    'finish_reason' => $finishReason,
                    'choice' => $choice,
                ]);
                return [
                    'success' => false,
                    'text' => null,
                    'tokens' => null,
                    'message' => $refusal !== null && $refusal !== ''
                        ? ('OpenAI refused the request: ' . $refusal)
                        : (
                            $finishReason === 'length' && $this->supportsReasoningEffort($model)
                                ? 'OpenAI hit the token limit before producing any visible output for this model. Please shorten your prompt, increase the token budget, or use gpt-4.1.'
                                : ('OpenAI returned an empty response.' . ($finishReason ? (' finish_reason=' . $finishReason) : ''))
                        ),
                    'raw' => $data,
                ];
            }

            $original = $content;
            $content = preg_replace('/^```\s*/i', '', $content) ?? $content;
            $content = preg_replace('/\s*```\s*$/', '', $content) ?? $content;
            $content = trim($content);

            if ($content === '') {
                $fallback = trim((string) $original);
                $looksLikeFenceOnly = preg_match('/^```\s*$/i', $fallback) === 1;

                if ($looksLikeFenceOnly) {
                    return [
                        'success' => false,
                        'text' => null,
                        'tokens' => null,
                        'message' => $finishReason === 'length'
                            ? 'OpenAI response was truncated due to token limit. Please shorten your prompt or increase the token budget.'
                            : 'OpenAI returned an empty response.',
                        'raw' => $data,
                    ];
                }

                $content = $fallback;
            }

            $tokens = $this->extractTotalTokens($data);

            return [
                'success' => true,
                'text' => $content,
                'tokens' => $tokens,
                'message' => null,
                'raw' => $data,
            ];
        } catch (\Throwable $e) {
            return [
                'success' => false,
                'text' => null,
                'tokens' => null,
                'message' => $e->getMessage(),
                'raw' => null,
            ];
        }
    }
}