diff --git a/src/Client.php b/src/Client.php index 3aca764f7..fb019c2e2 100644 --- a/src/Client.php +++ b/src/Client.php @@ -5,6 +5,7 @@ namespace OpenAI; use OpenAI\Contracts\ClientContract; +use OpenAI\Contracts\Resources\RealtimeContract; use OpenAI\Contracts\Resources\ThreadsContract; use OpenAI\Contracts\Resources\VectorStoresContract; use OpenAI\Contracts\TransporterContract; @@ -21,6 +22,7 @@ use OpenAI\Resources\Images; use OpenAI\Resources\Models; use OpenAI\Resources\Moderations; +use OpenAI\Resources\Realtime; use OpenAI\Resources\Responses; use OpenAI\Resources\Threads; use OpenAI\Resources\VectorStores; @@ -168,6 +170,16 @@ public function assistants(): Assistants return new Assistants($this->transporter); } + /** + * Communicate with a model in real time using WebRTC or WebSockets. + * + * @see https://platform.openai.com/docs/api-reference/realtime + */ + public function realtime(): RealtimeContract + { + return new Realtime($this->transporter); + } + /** * Create threads that assistants can interact with. * diff --git a/src/Contracts/ClientContract.php b/src/Contracts/ClientContract.php index daf447290..f67c7e368 100644 --- a/src/Contracts/ClientContract.php +++ b/src/Contracts/ClientContract.php @@ -15,6 +15,7 @@ use OpenAI\Contracts\Resources\ImagesContract; use OpenAI\Contracts\Resources\ModelsContract; use OpenAI\Contracts\Resources\ModerationsContract; +use OpenAI\Contracts\Resources\RealtimeContract; use OpenAI\Contracts\Resources\ResponsesContract; use OpenAI\Contracts\Resources\ThreadsContract; use OpenAI\Contracts\Resources\VectorStoresContract; @@ -36,6 +37,13 @@ public function completions(): CompletionsContract; */ public function responses(): ResponsesContract; + /** + * Communicate with a GPT-4o class model in real time using WebRTC or WebSockets. Supports text and audio inputs and outputs, along with audio transcriptions. + * + * @see https://platform.openai.com/docs/api-reference/realtime-sessions + */ + public function realtime(): RealtimeContract; + /** * Given a chat conversation, the model will return a chat completion response. * diff --git a/src/Contracts/Resources/RealtimeContract.php b/src/Contracts/Resources/RealtimeContract.php new file mode 100644 index 000000000..cb2b216e5 --- /dev/null +++ b/src/Contracts/Resources/RealtimeContract.php @@ -0,0 +1,29 @@ + $parameters + */ + public function token(array $parameters = []): SessionResponse; + + /** + * Create an ephemeral API token for real time transcription sessions. + * + * @see https://platform.openai.com/docs/api-reference/realtime-sessions/create-transcription + * + * @param array $parameters + */ + public function transcribeToken(array $parameters = []): TranscriptionSessionResponse; +} diff --git a/src/Resources/Realtime.php b/src/Resources/Realtime.php new file mode 100644 index 000000000..5d536fcb9 --- /dev/null +++ b/src/Resources/Realtime.php @@ -0,0 +1,54 @@ + $parameters + */ + public function token(array $parameters = []): SessionResponse + { + $payload = Payload::create('realtime/sessions', $parameters); + + /** @var Response $response */ + $response = $this->transporter->requestObject($payload); + + return SessionResponse::from($response->data()); + } + + /** + * Create an ephemeral API token for real time transcription sessions. + * + * @see https://platform.openai.com/docs/api-reference/realtime-sessions/create-transcription + * + * @param array $parameters + */ + public function transcribeToken(array $parameters = []): TranscriptionSessionResponse + { + $payload = Payload::create('realtime/transcription_sessions', $parameters); + + /** @var Response $response */ + $response = $this->transporter->requestObject($payload); + + return TranscriptionSessionResponse::from($response->data()); + } +} diff --git a/src/Responses/Realtime/Session/ClientSecret.php b/src/Responses/Realtime/Session/ClientSecret.php new file mode 100644 index 000000000..1cb90100d --- /dev/null +++ b/src/Responses/Realtime/Session/ClientSecret.php @@ -0,0 +1,51 @@ + + */ +final class ClientSecret implements ResponseContract +{ + /** + * @use ArrayAccessible + */ + use ArrayAccessible; + + use Fakeable; + + private function __construct( + public readonly int $expiresAt, + public readonly string $value, + ) {} + + /** + * @param ClientSecretType $attributes + */ + public static function from(array $attributes): self + { + return new self( + expiresAt: $attributes['expires_at'], + value: $attributes['value'], + ); + } + + /** + * {@inheritDoc} + */ + public function toArray(): array + { + return [ + 'expires_at' => $this->expiresAt, + 'value' => $this->value, + ]; + } +} diff --git a/src/Responses/Realtime/Session/InputAudioTranscription.php b/src/Responses/Realtime/Session/InputAudioTranscription.php new file mode 100644 index 000000000..92277750f --- /dev/null +++ b/src/Responses/Realtime/Session/InputAudioTranscription.php @@ -0,0 +1,51 @@ + + */ +final class InputAudioTranscription implements ResponseContract +{ + /** + * @use ArrayAccessible + */ + use ArrayAccessible; + + use Fakeable; + + /** + * @param "whisper-1" $model + */ + private function __construct( + public readonly string $model + ) {} + + /** + * @param InputAudioTranscriptionType $attributes + */ + public static function from(array $attributes): self + { + return new self( + model: $attributes['model'], + ); + } + + /** + * {@inheritDoc} + */ + public function toArray(): array + { + return [ + 'model' => $this->model, + ]; + } +} diff --git a/src/Responses/Realtime/Session/TurnDetection.php b/src/Responses/Realtime/Session/TurnDetection.php new file mode 100644 index 000000000..7ef6eee47 --- /dev/null +++ b/src/Responses/Realtime/Session/TurnDetection.php @@ -0,0 +1,60 @@ + + */ +final class TurnDetection implements ResponseContract +{ + /** + * @use ArrayAccessible + */ + use ArrayAccessible; + + use Fakeable; + + /** + * @param 'server_vad' $type + */ + private function __construct( + public readonly int $prefixPaddingMs, + public readonly int $silenceDurationMs, + public readonly float $threshold, + public readonly string $type, + ) {} + + /** + * @param TurnDetectionType $attributes + */ + public static function from(array $attributes): self + { + return new self( + prefixPaddingMs: $attributes['prefix_padding_ms'], + silenceDurationMs: $attributes['silence_duration_ms'], + threshold: $attributes['threshold'], + type: $attributes['type'], + ); + } + + /** + * {@inheritDoc} + */ + public function toArray(): array + { + return [ + 'prefix_padding_ms' => $this->prefixPaddingMs, + 'silence_duration_ms' => $this->silenceDurationMs, + 'threshold' => $this->threshold, + 'type' => $this->type, + ]; + } +} diff --git a/src/Responses/Realtime/SessionResponse.php b/src/Responses/Realtime/SessionResponse.php new file mode 100644 index 000000000..0e4efc329 --- /dev/null +++ b/src/Responses/Realtime/SessionResponse.php @@ -0,0 +1,113 @@ +, output_audio_format: 'pcm16'|'g711_ulaw'|'g711_alaw', temperature: float, tool_choice: 'auto'|'none'|'required', tools: array, turn_detection: TurnDetectionType|null, voice: 'alloy'|'ash'|'ballad'|'coral'|'echo'|'sage'|'shimmer'|'verse'} + * + * @implements ResponseContract + */ +final class SessionResponse implements ResponseContract +{ + /** + * @use ArrayAccessible + */ + use ArrayAccessible; + + use Fakeable; + + /** + * @param 'pcm16'|'g711_ulaw'|'g711_alaw' $inputAudioFormat + * @param int|'inf' $maxResponseOutputTokens + * @param array $modalities + * @param 'pcm16'|'g711_ulaw'|'g711_alaw' $outputAudioFormat + * @param 'auto'|'none'|'required' $toolChoice + * @param array $tools + * @param 'alloy'|'ash'|'ballad'|'coral'|'echo'|'sage'|'shimmer'|'verse' $voice + */ + private function __construct( + public readonly ClientSecret $clientSecret, + public readonly string $inputAudioFormat, + public readonly ?InputAudioTranscription $inputAudioTranscription, + public readonly string $instructions, + public readonly int|string $maxResponseOutputTokens, + public readonly array $modalities, + public readonly string $outputAudioFormat, + public readonly float $temperature, + public readonly string $toolChoice, + public readonly array $tools, + public readonly ?TurnDetection $turnDetection, + public readonly string $voice, + ) {} + + /** + * @param SessionType $attributes + */ + public static function from(array $attributes): self + { + $tools = array_map( + fn (array $tool): FunctionTool => match ($tool['type']) { + 'function' => FunctionTool::from($tool), + }, + $attributes['tools'] + ); + + return new self( + clientSecret: ClientSecret::from($attributes['client_secret']), + inputAudioFormat: $attributes['input_audio_format'], + inputAudioTranscription: isset($attributes['input_audio_transcription']) + ? InputAudioTranscription::from($attributes['input_audio_transcription']) + : null, + instructions: $attributes['instructions'], + maxResponseOutputTokens: $attributes['max_response_output_tokens'], + modalities: $attributes['modalities'], + outputAudioFormat: $attributes['output_audio_format'], + temperature: $attributes['temperature'], + toolChoice: $attributes['tool_choice'], + tools: $tools, + turnDetection: isset($attributes['turn_detection']) + ? TurnDetection::from($attributes['turn_detection']) + : null, + voice: $attributes['voice'], + ); + } + + /** + * {@inheritDoc} + */ + public function toArray(): array + { + return [ + 'client_secret' => $this->clientSecret->toArray(), + 'input_audio_format' => $this->inputAudioFormat, + 'input_audio_transcription' => $this->inputAudioTranscription?->toArray(), + 'instructions' => $this->instructions, + 'max_response_output_tokens' => $this->maxResponseOutputTokens, + 'modalities' => $this->modalities, + 'output_audio_format' => $this->outputAudioFormat, + 'temperature' => $this->temperature, + 'tool_choice' => $this->toolChoice, + 'tools' => array_map( + static fn (FunctionTool $tool): array => $tool->toArray(), + $this->tools, + ), + 'turn_detection' => $this->turnDetection?->toArray(), + 'voice' => $this->voice, + ]; + } +} diff --git a/src/Responses/Realtime/Tools/FunctionTool.php b/src/Responses/Realtime/Tools/FunctionTool.php new file mode 100644 index 000000000..f0d807eb4 --- /dev/null +++ b/src/Responses/Realtime/Tools/FunctionTool.php @@ -0,0 +1,61 @@ +, type: 'function'} + * + * @implements ResponseContract + */ +final class FunctionTool implements ResponseContract +{ + /** + * @use ArrayAccessible + */ + use ArrayAccessible; + + use Fakeable; + + /** + * @param array $parameters + * @param 'function' $type + */ + private function __construct( + public readonly string $description, + public readonly string $name, + public readonly array $parameters, + public readonly string $type, + ) {} + + /** + * @param FunctionToolType $attributes + */ + public static function from(array $attributes): self + { + return new self( + description: $attributes['description'], + name: $attributes['name'], + parameters: $attributes['parameters'], + type: $attributes['type'], + ); + } + + /** + * {@inheritDoc} + */ + public function toArray(): array + { + return [ + 'description' => $this->description, + 'name' => $this->name, + 'parameters' => $this->parameters, + 'type' => $this->type, + ]; + } +} diff --git a/src/Responses/Realtime/TranscriptionSession/InputAudioTranscription.php b/src/Responses/Realtime/TranscriptionSession/InputAudioTranscription.php new file mode 100644 index 000000000..71d9b87af --- /dev/null +++ b/src/Responses/Realtime/TranscriptionSession/InputAudioTranscription.php @@ -0,0 +1,57 @@ + + */ +final class InputAudioTranscription implements ResponseContract +{ + /** + * @use ArrayAccessible + */ + use ArrayAccessible; + + use Fakeable; + + /** + * @param 'gpt-4o-transcribe'|'gpt-4o-mini-transcribe'|"whisper-1" $model + */ + private function __construct( + public readonly string $language, + public readonly string $model, + public readonly string $prompt, + ) {} + + /** + * @param InputAudioTranscriptionType $attributes + */ + public static function from(array $attributes): self + { + return new self( + language: $attributes['language'], + model: $attributes['model'], + prompt: $attributes['prompt'], + ); + } + + /** + * {@inheritDoc} + */ + public function toArray(): array + { + return [ + 'language' => $this->language, + 'model' => $this->model, + 'prompt' => $this->prompt, + ]; + } +} diff --git a/src/Responses/Realtime/TranscriptionSessionResponse.php b/src/Responses/Realtime/TranscriptionSessionResponse.php new file mode 100644 index 000000000..4243bc12f --- /dev/null +++ b/src/Responses/Realtime/TranscriptionSessionResponse.php @@ -0,0 +1,77 @@ +|null, turn_detection: TurnDetectionType|null} + * + * @implements ResponseContract + */ +final class TranscriptionSessionResponse implements ResponseContract +{ + /** + * @use ArrayAccessible + */ + use ArrayAccessible; + + use Fakeable; + + /** + * @param 'pcm16'|'g711_ulaw'|'g711_alaw' $inputAudioFormat + * @param array|null $modalities + */ + private function __construct( + public readonly ClientSecret $clientSecret, + public readonly string $inputAudioFormat, + public readonly ?InputAudioTranscription $inputAudioTranscription, + public readonly ?array $modalities, + public readonly ?TurnDetection $turnDetection, + ) {} + + /** + * @param TranscriptionSessionType $attributes + */ + public static function from(array $attributes): self + { + return new self( + clientSecret: ClientSecret::from($attributes['client_secret']), + inputAudioFormat: $attributes['input_audio_format'], + inputAudioTranscription: isset($attributes['input_audio_transcription']) + ? InputAudioTranscription::from($attributes['input_audio_transcription']) + : null, + modalities: $attributes['modalities'] ?? null, + turnDetection: isset($attributes['turn_detection']) + ? TurnDetection::from($attributes['turn_detection']) + : null, + ); + } + + /** + * {@inheritDoc} + */ + public function toArray(): array + { + return [ + 'client_secret' => $this->clientSecret->toArray(), + 'input_audio_format' => $this->inputAudioFormat, + 'input_audio_transcription' => $this->inputAudioTranscription?->toArray(), + 'modalities' => $this->modalities, + 'turn_detection' => $this->turnDetection?->toArray(), + ]; + } +} diff --git a/src/Testing/ClientFake.php b/src/Testing/ClientFake.php index c6e074812..4fcf826c5 100644 --- a/src/Testing/ClientFake.php +++ b/src/Testing/ClientFake.php @@ -21,6 +21,7 @@ use OpenAI\Testing\Resources\ImagesTestResource; use OpenAI\Testing\Resources\ModelsTestResource; use OpenAI\Testing\Resources\ModerationsTestResource; +use OpenAI\Testing\Resources\RealtimeTestResource; use OpenAI\Testing\Resources\ResponsesTestResource; use OpenAI\Testing\Resources\ThreadsTestResource; use OpenAI\Testing\Resources\VectorStoresTestResource; @@ -138,6 +139,11 @@ public function responses(): ResponsesTestResource return new ResponsesTestResource($this); } + public function realtime(): RealtimeTestResource + { + return new RealtimeTestResource($this); + } + public function completions(): CompletionsTestResource { return new CompletionsTestResource($this); diff --git a/src/Testing/Resources/RealtimeTestResource.php b/src/Testing/Resources/RealtimeTestResource.php new file mode 100644 index 000000000..6addab4a8 --- /dev/null +++ b/src/Testing/Resources/RealtimeTestResource.php @@ -0,0 +1,29 @@ +record(__FUNCTION__, func_get_args()); + } + + public function transcribeToken(array $parameters = []): TranscriptionSessionResponse + { + return $this->record(__FUNCTION__, func_get_args()); + } +} diff --git a/src/Testing/Responses/Fixtures/Realtime/SessionResponseFixture.php b/src/Testing/Responses/Fixtures/Realtime/SessionResponseFixture.php new file mode 100644 index 000000000..3af1d3ace --- /dev/null +++ b/src/Testing/Responses/Fixtures/Realtime/SessionResponseFixture.php @@ -0,0 +1,32 @@ + [ + 'expires_at' => 1735680000, + 'value' => 'ek_secret_123', + ], + 'input_audio_format' => 'pcm16', + 'input_audio_transcription' => null, + 'instructions' => 'Your knowledge cutoff is 2023-10. You are a helpful assistant.', + 'max_response_output_tokens' => 'inf', + 'modalities' => [ + 'audio', + 'text', + ], + 'output_audio_format' => 'pcm16', + 'temperature' => 0.7, + 'tool_choice' => 'auto', + 'tools' => [], + 'turn_detection' => [ + 'prefix_padding_ms' => 100, + 'silence_duration_ms' => 500, + 'threshold' => 0.5, + 'type' => 'server_vad', + ], + 'voice' => 'alloy', + ]; +} diff --git a/src/Testing/Responses/Fixtures/Realtime/TranscriptionSessionResponseFixture.php b/src/Testing/Responses/Fixtures/Realtime/TranscriptionSessionResponseFixture.php new file mode 100644 index 000000000..caf4cd5bb --- /dev/null +++ b/src/Testing/Responses/Fixtures/Realtime/TranscriptionSessionResponseFixture.php @@ -0,0 +1,22 @@ + [ + 'expires_at' => 1735680000, + 'value' => 'ek_secret_123', + ], + 'input_audio_format' => 'pcm16', + 'input_audio_transcription' => null, + 'modalities' => null, + 'turn_detection' => [ + 'prefix_padding_ms' => 300, + 'silence_duration_ms' => 200, + 'threshold' => 0.5, + 'type' => 'server_vad', + ], + ]; +} diff --git a/tests/Fixtures/Realtime.php b/tests/Fixtures/Realtime.php new file mode 100644 index 000000000..b027b71c7 --- /dev/null +++ b/tests/Fixtures/Realtime.php @@ -0,0 +1,55 @@ + + */ +function sessionResponseResource(): array +{ + return [ + 'client_secret' => [ + 'expires_at' => 1735680000, + 'value' => 'ek_secret_123', + ], + 'input_audio_format' => 'pcm16', + 'input_audio_transcription' => null, + 'instructions' => 'Your knowledge cutoff is 2023-10. You are a helpful assistant.', + 'max_response_output_tokens' => 'inf', + 'modalities' => [ + 'audio', + 'text', + ], + 'output_audio_format' => 'pcm16', + 'temperature' => 0.7, + 'tool_choice' => 'auto', + 'tools' => [], + 'turn_detection' => [ + 'prefix_padding_ms' => 100, + 'silence_duration_ms' => 500, + 'threshold' => 0.5, + 'type' => 'server_vad', + ], + 'voice' => 'alloy', + ]; +} + +/** + * @return array + */ +function transcriptionSessionResponseResource(): array +{ + return [ + 'client_secret' => [ + 'expires_at' => 1735680000, + 'value' => 'ek_secret_345', + ], + 'input_audio_format' => 'pcm16', + 'input_audio_transcription' => null, + 'modalities' => null, + 'turn_detection' => [ + 'prefix_padding_ms' => 300, + 'silence_duration_ms' => 200, + 'threshold' => 0.5, + 'type' => 'server_vad', + ], + ]; +} diff --git a/tests/Resources/Realtime.php b/tests/Resources/Realtime.php new file mode 100644 index 000000000..478a446b4 --- /dev/null +++ b/tests/Resources/Realtime.php @@ -0,0 +1,24 @@ +realtime()->token(); + + expect($result) + ->toBeInstanceOf(SessionResponse::class) + ->clientSecret->value->toBe('ek_secret_123'); +}); + +test('transcription token', function () { + $client = mockClient('POST', 'realtime/transcription_sessions', [], \OpenAI\ValueObjects\Transporter\Response::from(transcriptionSessionResponseResource(), metaHeaders())); + + $result = $client->realtime()->transcribeToken(); + + expect($result) + ->toBeInstanceOf(TranscriptionSessionResponse::class) + ->clientSecret->value->toBe('ek_secret_345'); +}); diff --git a/tests/Responses/Realtime/SessionResponse.php b/tests/Responses/Realtime/SessionResponse.php new file mode 100644 index 000000000..5b78d7b51 --- /dev/null +++ b/tests/Responses/Realtime/SessionResponse.php @@ -0,0 +1,24 @@ +toBeInstanceOf(SessionResponse::class) + ->clientSecret->toBeInstanceOf(ClientSecret::class) + ->inputAudioFormat->toBe('pcm16') + ->inputAudioTranscription->toBeNull() + ->instructions->toBe('Your knowledge cutoff is 2023-10. You are a helpful assistant.') + ->maxResponseOutputTokens->toBe('inf') + ->modalities->toBe(['audio', 'text']) + ->outputAudioFormat->toBe('pcm16') + ->temperature->toBe(0.7) + ->toolChoice->toBe('auto') + ->tools->toBeArray() + ->turnDetection->toBeInstanceOf(TurnDetection::class) + ->voice->toBe('alloy'); +}); diff --git a/tests/Responses/Realtime/TranscriptionSessionResponse.php b/tests/Responses/Realtime/TranscriptionSessionResponse.php new file mode 100644 index 000000000..7e57fc500 --- /dev/null +++ b/tests/Responses/Realtime/TranscriptionSessionResponse.php @@ -0,0 +1,17 @@ +toBeInstanceOf(TranscriptionSessionResponse::class) + ->clientSecret->toBeInstanceOf(ClientSecret::class) + ->inputAudioFormat->toBe('pcm16') + ->inputAudioTranscription->toBeNull() + ->modalities->toBeNull() + ->turnDetection->toBeInstanceOf(TurnDetection::class); +}); diff --git a/tests/Testing/Resources/RealtimeTestResource.php b/tests/Testing/Resources/RealtimeTestResource.php new file mode 100644 index 000000000..92319d8a7 --- /dev/null +++ b/tests/Testing/Resources/RealtimeTestResource.php @@ -0,0 +1,30 @@ +realtime()->token(); + + $fake->assertSent(Realtime::class, function ($method) { + return $method === 'token'; + }); +}); + +it('records a realtime token transcription request', function () { + $fake = new ClientFake([ + TranscriptionSessionResponse::fake(), + ]); + + $fake->realtime()->transcribeToken(); + + $fake->assertSent(Realtime::class, function ($method) { + return $method === 'transcribeToken'; + }); +});