| <?php |
|
|
| namespace App\Services; |
|
|
| use Illuminate\Support\Facades\Http; |
| use Illuminate\Support\Str; |
| use RuntimeException; |
|
|
| class OpenAiChatService |
| { |
| public function assistantReply( |
| string $prompt, |
| ?string $threadId = null, |
| ?string $additionalInstructions = null, |
| ?string $model = null |
| ): array { |
| $assistantId = trim((string) config('services.openai.assistant_id')); |
| $baseUrl = rtrim((string) config('services.openai.base_url', 'https://api.openai.com/v1'), '/'); |
| $apiKey = $this->resolveApiKey($baseUrl); |
| $timeout = max(20, (int) config('services.openai.timeout', 40)); |
| $runTimeout = max($timeout, (int) config('services.openai.assistant_run_timeout', 90)); |
| $pollIntervalMs = max(300, (int) config('services.openai.assistant_poll_interval_ms', 1000)); |
|
|
| if ($assistantId === '') { |
| throw new RuntimeException('OPENAI_ASSISTANT_ID belum diisi di file .env'); |
| } |
|
|
| if (trim($apiKey) === '') { |
| throw new RuntimeException('OPENAI_API_KEY belum diisi di file .env'); |
| } |
|
|
| $activeThreadId = trim((string) $threadId); |
| if ($activeThreadId === '') { |
| $activeThreadId = (string) data_get( |
| $this->assistantRequest('post', "{$baseUrl}/threads", [], $apiKey, $timeout), |
| 'id', |
| '' |
| ); |
| } |
|
|
| if ($activeThreadId === '') { |
| throw new RuntimeException('OpenAI thread tidak berhasil dibuat.'); |
| } |
|
|
| try { |
| $this->assistantRequest('post', "{$baseUrl}/threads/{$activeThreadId}/messages", [ |
| 'role' => 'user', |
| 'content' => $prompt, |
| ], $apiKey, $timeout); |
| } catch (RuntimeException $e) { |
| if (! $this->shouldRefreshAssistantThread($threadId, $e->getMessage())) { |
| throw $e; |
| } |
|
|
| $activeThreadId = (string) data_get( |
| $this->assistantRequest('post', "{$baseUrl}/threads", [], $apiKey, $timeout), |
| 'id', |
| '' |
| ); |
|
|
| if ($activeThreadId === '') { |
| throw new RuntimeException('OpenAI thread baru tidak berhasil dibuat.'); |
| } |
|
|
| $this->assistantRequest('post', "{$baseUrl}/threads/{$activeThreadId}/messages", [ |
| 'role' => 'user', |
| 'content' => $prompt, |
| ], $apiKey, $timeout); |
| } |
|
|
| $runPayload = [ |
| 'assistant_id' => $assistantId, |
| ]; |
|
|
| $trimmedInstructions = trim((string) $additionalInstructions); |
| if ($trimmedInstructions !== '') { |
| $runPayload['additional_instructions'] = $trimmedInstructions; |
| } |
|
|
| $trimmedModel = trim((string) $model); |
| if ($trimmedModel !== '') { |
| $runPayload['model'] = $trimmedModel; |
| } |
|
|
| $run = $this->assistantRequest('post', "{$baseUrl}/threads/{$activeThreadId}/runs", $runPayload, $apiKey, $timeout); |
| $runId = (string) data_get($run, 'id', ''); |
|
|
| if ($runId === '') { |
| throw new RuntimeException('OpenAI run tidak berhasil dibuat.'); |
| } |
|
|
| $deadline = microtime(true) + $runTimeout; |
|
|
| while (true) { |
| $latestRun = $this->assistantRequest('get', "{$baseUrl}/threads/{$activeThreadId}/runs/{$runId}", [], $apiKey, $timeout); |
| $status = (string) data_get($latestRun, 'status', ''); |
|
|
| if ($status === 'completed') { |
| break; |
| } |
|
|
| if ($status === 'requires_action') { |
| throw new RuntimeException('OpenAI Assistant meminta tool action tambahan yang belum didukung di flow ini.'); |
| } |
|
|
| if (in_array($status, ['failed', 'cancelled', 'expired'], true)) { |
| $error = trim((string) data_get($latestRun, 'last_error.message', '')); |
| throw new RuntimeException('OpenAI Assistant run gagal'.($error !== '' ? ': '.$error : '.')); |
| } |
|
|
| if (microtime(true) >= $deadline) { |
| throw new RuntimeException('OpenAI Assistant timeout saat menunggu run selesai.'); |
| } |
|
|
| usleep($pollIntervalMs * 1000); |
| } |
|
|
| $messages = $this->assistantRequest('get', "{$baseUrl}/threads/{$activeThreadId}/messages", [ |
| 'limit' => 10, |
| 'order' => 'desc', |
| ], $apiKey, $timeout); |
|
|
| $content = $this->extractAssistantMessage((array) data_get($messages, 'data', [])); |
| if ($content === '') { |
| throw new RuntimeException('Respons Assistant OpenAI kosong atau format tidak valid.'); |
| } |
|
|
| return [ |
| 'thread_id' => $activeThreadId, |
| 'content' => $content, |
| ]; |
| } |
|
|
| public function reply(array $messages): string |
| { |
| $baseUrl = rtrim((string) (config('services.openai.chat_base_url') ?: config('services.openai.base_url', 'https://api.openai.com/v1')), '/'); |
| $apiKey = $this->resolveApiKey($baseUrl); |
| $model = (string) (config('services.openai.chat_model') ?: config('services.openai.model', 'gpt-4o')); |
| $timeout = max(15, (int) config('services.openai.timeout', 40)); |
| $maxTokens = max(64, (int) config('services.openai.max_tokens', 180)); |
|
|
| if (trim($apiKey) === '') { |
| throw new RuntimeException('OPENAI_API_KEY belum diisi di file .env'); |
| } |
|
|
| $candidateModels = $this->candidateModels($baseUrl, $model); |
| $lastError = null; |
|
|
| foreach ($candidateModels as $candidateModel) { |
| $response = Http::timeout($timeout) |
| ->withToken($apiKey) |
| ->acceptJson() |
| ->post("{$baseUrl}/chat/completions", [ |
| 'model' => $candidateModel, |
| 'messages' => $messages, |
| 'max_tokens' => $maxTokens, |
| 'temperature' => 0.3, |
| ]); |
|
|
| if (! $response->successful()) { |
| $errorBody = trim((string) data_get($response->json(), 'error.message', $response->body())); |
| $lastError = 'OpenAI request gagal: '.$response->status().' - '.$errorBody; |
|
|
| if ($this->shouldTryNextModel($baseUrl, $candidateModel, $response->status(), $errorBody)) { |
| continue; |
| } |
|
|
| throw new RuntimeException($lastError); |
| } |
|
|
| $content = data_get($response->json(), 'choices.0.message.content'); |
|
|
| if (is_array($content)) { |
| $content = collect($content) |
| ->pluck('text') |
| ->filter(fn ($item) => is_string($item) && trim($item) !== '') |
| ->implode("\n"); |
| } |
|
|
| if (! is_string($content) || trim($content) === '') { |
| $lastError = 'Respons OpenAI kosong atau format tidak valid.'; |
| continue; |
| } |
|
|
| return trim($content); |
| } |
|
|
| throw new RuntimeException($lastError ?: 'Respons chat tidak tersedia.'); |
| } |
|
|
| public function describeImage(string $imageUrl): string |
| { |
| $baseUrl = rtrim((string) config('services.openai.base_url', 'https://api.openai.com/v1'), '/'); |
| $apiKey = (string) (config('services.openai.api_key') ?: config('services.openai.chat_api_key')); |
| $model = (string) config('services.openai.model', 'gpt-4o'); |
| $timeout = max(20, (int) config('services.openai.timeout', 40)); |
|
|
| $trimmedApiKey = trim($apiKey); |
|
|
| if ($trimmedApiKey === '' || str_starts_with($trimmedApiKey, 'GANTI_') || str_starts_with($trimmedApiKey, 'YOUR_')) { |
| throw new RuntimeException('OPENAI_API_KEY belum diisi di file .env'); |
| } |
|
|
| $response = Http::timeout($timeout) |
| ->withToken($trimmedApiKey) |
| ->acceptJson() |
| ->post("{$baseUrl}/chat/completions", [ |
| 'model' => $model, |
| 'messages' => [ |
| [ |
| 'role' => 'system', |
| 'content' => 'Kamu adalah vision assistant. Deskripsikan gambar secara singkat, konkret, dan padat dalam Bahasa Indonesia. Maksimal 18 kata. Jangan pakai markdown atau kalimat pembuka.', |
| ], |
| [ |
| 'role' => 'user', |
| 'content' => [ |
| [ |
| 'type' => 'text', |
| 'text' => 'Buat satu deskripsi singkat yang akurat untuk gambar ini. Fokus pada subjek utama, konteks visual, dan gaya foto bila terlihat.', |
| ], |
| [ |
| 'type' => 'image_url', |
| 'image_url' => [ |
| 'url' => $imageUrl, |
| ], |
| ], |
| ], |
| ], |
| ], |
| 'max_tokens' => 80, |
| 'temperature' => 0.2, |
| ]); |
|
|
| if (! $response->successful()) { |
| $errorBody = trim((string) data_get($response->json(), 'error.message', $response->body())); |
| throw new RuntimeException('OpenAI vision request gagal: '.$response->status().' - '.$errorBody); |
| } |
|
|
| $content = data_get($response->json(), 'choices.0.message.content'); |
|
|
| if (is_array($content)) { |
| $content = collect($content) |
| ->pluck('text') |
| ->filter(fn ($item) => is_string($item) && trim($item) !== '') |
| ->implode("\n"); |
| } |
|
|
| $description = trim((string) $content); |
| if ($description === '') { |
| throw new RuntimeException('Respons vision OpenAI kosong atau format tidak valid.'); |
| } |
|
|
| return Str::limit($description, 180, ''); |
| } |
|
|
| private function resolveApiKey(string $baseUrl): string |
| { |
| $chatApiKey = (string) config('services.openai.chat_api_key'); |
| $hfToken = (string) config('services.huggingface.api_token'); |
| $openAiApiKey = (string) config('services.openai.api_key'); |
|
|
| if (str_contains($baseUrl, 'router.huggingface.co')) { |
| return (string) ($hfToken ?: $chatApiKey ?: $openAiApiKey); |
| } |
|
|
| return (string) ($chatApiKey ?: $openAiApiKey); |
| } |
|
|
| private function candidateModels(string $baseUrl, string $primaryModel): array |
| { |
| $models = [$primaryModel]; |
|
|
| $fallback = trim((string) config('services.openai.chat_fallback_model', '')); |
| if ($fallback !== '' && ! in_array($fallback, $models, true)) { |
| $models[] = $fallback; |
| } |
|
|
| if (str_contains($baseUrl, 'router.huggingface.co')) { |
| foreach ([ |
| 'CohereLabs/c4ai-command-r-08-2024:cohere', |
| 'CohereLabs/tiny-aya-global:cohere', |
| ] as $routerSafeModel) { |
| if (! in_array($routerSafeModel, $models, true)) { |
| $models[] = $routerSafeModel; |
| } |
| } |
| } |
|
|
| return array_values(array_filter($models, fn ($item) => is_string($item) && trim($item) !== '')); |
| } |
|
|
| private function shouldTryNextModel(string $baseUrl, string $model, int $status, string $errorBody): bool |
| { |
| if (! str_contains($baseUrl, 'router.huggingface.co')) { |
| return false; |
| } |
|
|
| if ($status === 403 && str_contains(strtolower($errorBody), 'browser_signature_banned')) { |
| return true; |
| } |
|
|
| if ($status === 400 && str_contains(strtolower($errorBody), 'not supported by provider')) { |
| return true; |
| } |
|
|
| return false; |
| } |
|
|
| private function assistantRequest(string $method, string $url, array $payload, string $apiKey, int $timeout): array |
| { |
| $request = Http::timeout($timeout) |
| ->withToken($apiKey) |
| ->acceptJson() |
| ->withHeaders([ |
| 'OpenAI-Beta' => 'assistants=v2', |
| ]); |
|
|
| $response = strtolower($method) === 'get' |
| ? $request->get($url, $payload) |
| : $request->post($url, $payload); |
|
|
| if (! $response->successful()) { |
| $errorBody = trim((string) data_get($response->json(), 'error.message', $response->body())); |
| throw new RuntimeException('OpenAI Assistant request gagal: '.$response->status().' - '.$errorBody); |
| } |
|
|
| return (array) $response->json(); |
| } |
|
|
| private function shouldRefreshAssistantThread(?string $threadId, string $errorMessage): bool |
| { |
| if (trim((string) $threadId) === '') { |
| return false; |
| } |
|
|
| $normalized = strtolower($errorMessage); |
|
|
| return str_contains($normalized, '404') |
| || str_contains($normalized, 'thread not found') |
| || str_contains($normalized, 'no thread found'); |
| } |
|
|
| private function extractAssistantMessage(array $messages): string |
| { |
| foreach ($messages as $message) { |
| if ((string) data_get($message, 'role', '') !== 'assistant') { |
| continue; |
| } |
|
|
| $content = collect((array) data_get($message, 'content', [])) |
| ->map(function ($part): string { |
| if ((string) data_get($part, 'type', '') !== 'text') { |
| return ''; |
| } |
|
|
| $value = data_get($part, 'text.value', data_get($part, 'text')); |
|
|
| return is_string($value) ? trim($value) : ''; |
| }) |
| ->filter(fn ($item) => $item !== '') |
| ->implode("\n"); |
|
|
| if ($content !== '') { |
| return trim($content); |
| } |
| } |
|
|
| return ''; |
| } |
| } |
|
|