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 ''; } }