'Original Whisper: cheapest, returns timestamped segments, weaker on Arabic.', 'gpt-4o-transcribe' => 'Most accurate, best on Arabic and on calls that switch between AR and EN. Billed per minute.', 'gpt-4o-mini-transcribe' => 'Faster and cheaper than gpt-4o-transcribe, close to it on clean audio.', ]; protected const CHAT_MODELS = [ 'gpt-4o-mini' => 'Cheapest summarizer; fine for short call notes.', 'gpt-4o' => 'Stronger reasoning; better at picking up commitments and follow-ups.', 'gpt-4.1-mini' => 'Newer small model, usually a drop-in improvement on gpt-4o-mini.', 'gpt-4.1' => 'Newer large model; best summaries, highest cost.', ]; public function driver(): string { return 'openai'; } public function test(AiVoiceProvider $provider): array { try { $response = Http::withToken($this->apiKey($provider)) ->timeout(20) ->acceptJson() ->get($this->baseUrl($provider, static::DEFAULT_BASE) . '/models'); if ($response->successful()) { return ['ok' => true, 'message' => 'Connected: the provider accepted the API key.']; } return ['ok' => false, 'message' => $this->errorMessage($response)]; } catch (\Throwable $e) { return ['ok' => false, 'message' => $e->getMessage()]; } } /** * Ask the vendor what this key can actually use, and split it into * "transcribes audio" and "writes the summary". Any failure falls back to * the built-in list with the reason attached, so the page never goes blank. */ public function catalog(AiVoiceProvider $provider): array { try { $response = Http::withToken($this->apiKey($provider)) ->timeout(20) ->acceptJson() ->get($this->baseUrl($provider, static::DEFAULT_BASE) . '/models'); } catch (\Throwable $e) { return $this->builtInCatalog() + ['source' => 'built-in', 'message' => $e->getMessage()]; } if (!$response->successful()) { return $this->builtInCatalog() + ['source' => 'built-in', 'message' => $this->errorMessage($response)]; } $ids = []; foreach ((array) data_get($response->json(), 'data', []) as $model) { $id = trim((string) ($model['id'] ?? '')); if ($id !== '') { $ids[] = $id; } } $catalog = $this->classify($ids); if (!$catalog['transcription']) { return $this->builtInCatalog() + [ 'source' => 'built-in', 'message' => 'The key can list ' . count($ids) . ' models, but none of them transcribe audio.', ]; } return $catalog + ['source' => 'live', 'message' => null]; } /** * Split a vendor model list into the two dropdowns. * * OpenAI and Groq share this shape but not their names, so the chat test is * a family match rather than a prefix: anything that is not a transcription, * speech, embedding or image model and belongs to a known text family. */ protected function classify(array $ids): array { $transcription = []; $chat = []; foreach ($ids as $id) { if (preg_match('/(whisper|transcribe)/i', $id)) { if (!preg_match('/(realtime|diarize)/i', $id)) { $transcription[] = ['id' => $id, 'note' => null]; } continue; } if (preg_match('/(tts|embed|moderation|image|dall-e|audio|realtime|guard|whisper|transcribe|codex|computer-use)/i', $id)) { continue; } if (preg_match('/(llama|gpt-|^o[1-4]|mistral|mixtral|qwen|deepseek|gemma|command|kimi)/i', $id)) { $chat[] = ['id' => $id, 'note' => null]; } } return [ 'transcription' => static::sortOptions($transcription), 'chat' => static::sortOptions($chat), ]; } public function analyse(AiVoiceProvider $provider, array $chunks, array $context): array { $transcripts = []; foreach ($chunks as $chunk) { $transcripts[] = $this->transcribe($provider, $chunk); } $transcript = $this->joinTranscripts($transcripts); if ($transcript === '') { throw new AiProviderException($this->driver(), 'No speech was detected in this recording (it may be silent, hold music, or too short to transcribe). Try another call.'); } $summary = $this->summarize($provider, $transcript, $context); return [ 'transcript' => $transcript, 'summary' => $summary, 'meta' => [ 'transcription_model' => $this->transcriptionModel($provider), 'analysis_model' => $this->analysisModel($provider), 'language' => $this->language($provider), 'summary_language' => $this->summaryLanguage($provider), 'chunks' => count($chunks), ], ]; } protected function transcriptionModel(AiVoiceProvider $provider): string { $model = trim((string) $provider->transcription_model); return $model !== '' ? $model : static::DEFAULT_TRANSCRIPTION_MODEL; } protected function analysisModel(AiVoiceProvider $provider): string { $model = trim((string) $provider->analysis_model); return $model !== '' ? $model : static::DEFAULT_ANALYSIS_MODEL; } protected function transcribe(AiVoiceProvider $provider, string $path): string { $model = $this->transcriptionModel($provider); // Only the whisper models return timestamps via verbose_json. The // gpt-4o-transcribe family answers HTTP 400 ("response_format // 'verbose_json' is not compatible with model …"), so ask for it only // where it is supported, and fall back if a vendor still objects. $response = $this->postTranscription($provider, $path, $model, $this->supportsVerboseJson($model)); if ($response->status() === 400 && stripos((string) $response->body(), 'response_format') !== false) { $response = $this->postTranscription($provider, $path, $model, false); } if (!$response->successful()) { throw new AiProviderException($this->driver(), $this->errorMessage($response)); } return $this->formatTranscript((array) $response->json()); } /** * whisper-1 is the family that can emit segments (and therefore timestamps); * the gpt-4o-transcribe models only do json/text. */ protected function supportsVerboseJson(string $model): bool { return stripos($model, 'whisper') !== false; } /** * One transcription request. A fresh file handle per attempt: the previous * one is consumed by the multipart upload. */ protected function postTranscription(AiVoiceProvider $provider, string $path, string $model, bool $verbose) { $handle = fopen($path, 'r'); if ($handle === false) { throw new AiProviderException($this->driver(), 'The prepared audio chunk could not be opened for upload.'); } try { return Http::withToken($this->apiKey($provider)) ->timeout($this->timeout()) ->acceptJson() ->attach('file', $handle, basename($path)) ->post($this->baseUrl($provider, static::DEFAULT_BASE) . '/audio/transcriptions', array_filter([ 'model' => $model, 'response_format' => $verbose ? 'verbose_json' : 'json', 'language' => $this->language($provider), ])); } finally { if (is_resource($handle)) { fclose($handle); } } } /** * Whisper cannot tell speakers apart, so the transcript carries timestamps * instead of speaker labels. */ protected function formatTranscript(array $data): string { $segments = $data['segments'] ?? null; if (is_array($segments) && $segments) { $lines = []; foreach ($segments as $segment) { $text = trim((string) ($segment['text'] ?? '')); if ($text === '') { continue; } $lines[] = '[' . self::timestampLabel((float) ($segment['start'] ?? 0)) . '] ' . $text; } if ($lines) { return implode("\n", $lines); } } return trim((string) ($data['text'] ?? '')); } protected function summarize(AiVoiceProvider $provider, string $transcript, array $context): string { $response = Http::withToken($this->apiKey($provider)) ->timeout($this->timeout()) ->acceptJson() ->post($this->baseUrl($provider, static::DEFAULT_BASE) . '/chat/completions', [ 'model' => $this->analysisModel($provider), 'temperature' => 0.2, 'messages' => [ ['role' => 'system', 'content' => $this->systemPrompt($provider)], ['role' => 'user', 'content' => $this->contextPreamble($context) . "Transcript:\n" . $transcript], ], ]); if (!$response->successful()) { throw new AiProviderException($this->driver(), $this->errorMessage($response)); } $summary = trim((string) data_get($response->json(), 'choices.0.message.content', '')); if ($summary === '') { throw new AiProviderException($this->driver(), 'The provider returned an empty summary.'); } return $summary; } }