teknolis / app /Services /OpenAiChatService.php
Codex
Deploy latest assistant, DeepSeek defaults, and automation updates
74acf19
Raw
History Blame Contribute Delete
13.9 kB
<?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 '';
}
}