com.google.code.findbugs
jsr305
diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/AudioInputChannel.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/AudioInputChannel.java
new file mode 100644
index 000000000..50bea1504
--- /dev/null
+++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/AudioInputChannel.java
@@ -0,0 +1,18 @@
+package com.sap.ai.sdk.foundationmodels.openai;
+
+/**
+ * Functional interface representing audio input channel (audio data consumer)
+ *
+ * Should be closed by application (try-with-resources) when not needed anymore
+ */
+public interface AudioInputChannel extends AutoCloseable {
+
+ /**
+ * This method is sequentially invoked by audio data provider to supply implementer (consumer)
+ * with the audio data. Exact audio format (encoding, sampling rate, etc.) depends on the usage
+ * context
+ *
+ * @param rawBytesChunk binary data in the depending on the use case format
+ */
+ void inputAudio(byte[] rawBytesChunk);
+}
diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/AudioOutputChannel.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/AudioOutputChannel.java
new file mode 100644
index 000000000..b190fbda5
--- /dev/null
+++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/AudioOutputChannel.java
@@ -0,0 +1,21 @@
+package com.sap.ai.sdk.foundationmodels.openai;
+
+/**
+ * Functional interface representing audio output channel (audio data consumer)
+ *
+ *
Should be closed by application (try-with-resources) when not needed anymore
+ */
+public interface AudioOutputChannel {
+
+ /**
+ * This method is sequentially invoked by audio data provider to supply implementer (consumer)
+ * with the audio data. Exact audio format (encoding, sampling rate, etc.) depends on the usage
+ * context
+ *
+ * @param rawBytesChunk binary data in the depending on the use case format
+ * @param isLast true if this call logically concludes previous and this passed bytes data into a
+ * single logical entity (e.g. gets called at the end when all byte parts of a single message
+ * get passed)
+ */
+ void outputAudio(byte[] rawBytesChunk, boolean isLast);
+}
diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/BufferedWebSocketListener.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/BufferedWebSocketListener.java
new file mode 100644
index 000000000..8d29cb358
--- /dev/null
+++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/BufferedWebSocketListener.java
@@ -0,0 +1,54 @@
+package com.sap.ai.sdk.foundationmodels.openai;
+
+import java.net.http.WebSocket;
+import java.nio.ByteBuffer;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionStage;
+import java.util.function.BiConsumer;
+import java.util.function.Consumer;
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
+public class BufferedWebSocketListener implements WebSocket.Listener {
+
+ private final Consumer onOpen;
+ private final BiConsumer onText;
+ private final StringBuilder buffer;
+
+ public BufferedWebSocketListener(
+ Consumer onOpen, BiConsumer onText) {
+ this.onOpen = onOpen;
+ this.onText = onText;
+ this.buffer = new StringBuilder(1024 * 1024);
+ }
+
+ @Override
+ public void onOpen(WebSocket webSocket) {
+ this.onOpen.accept(webSocket);
+ webSocket.request(1);
+ }
+
+ @Override
+ public CompletionStage> onText(WebSocket webSocket, CharSequence data, boolean last) {
+ buffer.append(data);
+ webSocket.request(1);
+ if (last) {
+ var completeMessage = buffer.toString();
+ buffer.setLength(0);
+ this.onText.accept(webSocket, completeMessage);
+ }
+
+ return CompletableFuture.completedStage(null);
+ }
+
+ @Override
+ public void onError(WebSocket webSocket, Throwable error) {
+ log.error("Websocket error occurred during realtime communication", error);
+ }
+
+ @Override
+ public CompletionStage> onBinary(WebSocket webSocket, ByteBuffer data, boolean last) {
+ log.warn("Received unexpected binary bytes for WebSocket connection");
+ return CompletableFuture.completedStage(null);
+ }
+}
diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiClient.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiClient.java
index ce981fa2a..00f803d3d 100644
--- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiClient.java
+++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiClient.java
@@ -15,6 +15,7 @@
import com.sap.ai.sdk.core.common.ClientStreamingHandler;
import com.sap.ai.sdk.core.common.RequestLogContext;
import com.sap.ai.sdk.core.common.StreamedDelta;
+import com.sap.ai.sdk.core.model.SpeechOutputParam;
import com.sap.ai.sdk.foundationmodels.openai.generated.model.ChatCompletionStreamOptions;
import com.sap.ai.sdk.foundationmodels.openai.generated.model.CreateChatCompletionRequest;
import com.sap.ai.sdk.foundationmodels.openai.generated.model.CreateChatCompletionResponse;
@@ -33,6 +34,7 @@
import com.sap.cloud.sdk.cloudplatform.connectivity.HttpDestination;
import java.io.IOException;
import java.util.ArrayList;
+import java.util.HashMap;
import java.util.List;
import java.util.stream.Stream;
import javax.annotation.Nonnull;
@@ -50,6 +52,8 @@
@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
public final class OpenAiClient {
private static final String DEFAULT_API_VERSION = "2024-02-01";
+ private static final int PATH_BUFFER_SIZE =
+ 400; // existing URLs are ~120 symbols long, 400 has reasonable margin
private static final ObjectMapper JACKSON = getOpenAiObjectMapper();
@@ -260,6 +264,80 @@ public Stream streamChatCompletion(@Nonnull final String prompt)
.map(OpenAiChatCompletionDelta::getDeltaContent);
}
+ /**
+ * Creates realtime channel allowing to input text and voice it (receive audio output)
+ *
+ * The input channel should be used with a try-with-resources block to ensure that the
+ * underlying connection is closed.
+ *
+ *
Example:
+ *
+ *
{@code
+ * try (var textInputChannel = client.textToSpeech(audioOutputConsumer)) {
+ * textInputChannel.sendText("...");
+ * ....
+ * }
+ * }
+ *
+ * This API implements full duplex (input + output) communication channels. Application should
+ * logically synchronize their state and close input channel when it is appropriate (e.g. last
+ * part of the response has been received via output channel and application does not need to send
+ * any other input). When input channel is closed, output channel will be closed automatically and
+ * output consumer will not be called anymore.
+ *
+ * @param audioOutputConsumer - audio consumer of raw PCM mono 24000 Hz little endian output
+ * @return input channel, allowing for text input
+ */
+ @Nonnull
+ public TextInputChannel textToSpeech(
+ @Nonnull final AudioOutputChannel audioOutputConsumer, SpeechOutputParam... params) {
+ var extraHeaders = destination.asHttp().getHeaders();
+ var headers = new HashMap(extraHeaders.size() + 1);
+ for (Header header : extraHeaders) {
+ headers.put(header.getName(), header.getValue());
+ }
+ var endpoint = getRealtimeEndpoint();
+
+ return new TextToSpeechRealtimeClient(endpoint, headers, audioOutputConsumer, params);
+ }
+
+ // unstable, requires further debugging and testing
+ AudioInputChannel speechToText(@Nonnull final TextOutputChannel textOutputConsumer) {
+ var extraHeaders = destination.asHttp().getHeaders();
+ var headers = new HashMap(extraHeaders.size() + 1);
+ for (Header header : extraHeaders) {
+ headers.put(header.getName(), header.getValue());
+ }
+ var endpoint = getRealtimeEndpoint() + "?intent=transcription";
+
+ return new SpeechToTextRealtimeClient(endpoint, headers, textOutputConsumer);
+ }
+
+ public AudioInputChannel speechToSpeech(
+ @Nonnull final AudioOutputChannel audioOutputChannel, SpeechOutputParam... params) {
+ var extraHeaders = destination.asHttp().getHeaders();
+ var headers = new HashMap(extraHeaders.size() + 1);
+ for (Header header : extraHeaders) {
+ headers.put(header.getName(), header.getValue());
+ }
+ var endpoint = getRealtimeEndpoint();
+
+ return new SpeechToSpeechRealtimeClient(endpoint, headers, audioOutputChannel, params);
+ }
+
+ private String getRealtimeEndpoint() {
+ var sb = new StringBuilder(PATH_BUFFER_SIZE);
+ sb.append("wss://");
+ var pathParts = destination.asHttp().getUri().toString().split("//");
+ if (pathParts.length != 2) {
+ throw new IllegalArgumentException(
+ "Invalid destination URI: " + destination.asHttp().getUri());
+ }
+ sb.append(pathParts[1].replaceFirst("^api\\.", "realtime."));
+ sb.append("v1/realtime");
+ return sb.toString();
+ }
+
private static void throwOnContentFilter(@Nonnull final OpenAiChatCompletionDelta delta) {
final String finishReason = delta.getFinishReason();
if (finishReason != null && finishReason.equals("content_filter")) {
diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiModel.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiModel.java
index 1926f56ce..62c435bcc 100644
--- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiModel.java
+++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiModel.java
@@ -121,7 +121,7 @@ public record OpenAiModel(@Nonnull String name, @Nullable String version) implem
/** Azure OpenAI GPT-5-nano model */
public static final OpenAiModel GPT_5_NANO = new OpenAiModel("gpt-5-nano", null);
- /** Azure OpenAI GPT-5-nano model */
+ /** Azure OpenAI GPT-realtime model */
public static final OpenAiModel GPT_REALTIME = new OpenAiModel("gpt-realtime", null);
/** Azure OpenAI GPT-5.2 model */
diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/SpeechToSpeechRealtimeClient.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/SpeechToSpeechRealtimeClient.java
new file mode 100644
index 000000000..3202f1c1f
--- /dev/null
+++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/SpeechToSpeechRealtimeClient.java
@@ -0,0 +1,142 @@
+package com.sap.ai.sdk.foundationmodels.openai;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.openai.models.realtime.*;
+import com.openai.models.realtime.clientsecrets.ClientSecretCreateParams;
+import com.sap.ai.sdk.core.model.SpeechOutputParam;
+import com.sap.ai.sdk.core.model.SpeechOutputParamTurnDetection;
+import com.sap.ai.sdk.core.model.SpeechOutputParamVoice;
+import java.util.*;
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
+class SpeechToSpeechRealtimeClient extends WSSOpenAIRealtimeClient implements AudioInputChannel {
+
+ private static final int MAX_DATA_CHUNK_SIZE_BYTES = 8192;
+ private static final byte[] EMPTY_BYTE_ARRAY = new byte[0];
+
+ private static final Set HANDLED_RESPONSE_TYPES =
+ Set.of("response.output_audio.delta", "response.output_audio.done");
+
+ private static final List OUTPUT_MODALITIES =
+ List.of(RealtimeSessionCreateRequest.OutputModality.AUDIO);
+
+ private static final String TASK = "";
+
+ private final AudioOutputChannel outputConsumer;
+ private final RealtimeAudioConfigOutput.Voice.UnionMember1 voice;
+ private final boolean eagerTurnDetection;
+
+ public SpeechToSpeechRealtimeClient(
+ String url,
+ Map httpHeaders,
+ AudioOutputChannel outputConsumer,
+ SpeechOutputParam... params) {
+ super(url, httpHeaders, HANDLED_RESPONSE_TYPES);
+ this.outputConsumer = outputConsumer;
+
+ var voice = RealtimeAudioConfigOutput.Voice.UnionMember1.MARIN;
+ var turnDetectionEager = false;
+ for (SpeechOutputParam param : params) {
+ switch (param.getParamName()) {
+ case VOICE -> {
+ if (SpeechOutputParamVoice.DEFAULT_WOMAN.equals(param)) {
+ voice = RealtimeAudioConfigOutput.Voice.UnionMember1.MARIN;
+ } else if (SpeechOutputParamVoice.DEFAULT_MAN.equals(param)) {
+ voice = RealtimeAudioConfigOutput.Voice.UnionMember1.ECHO;
+ }
+ }
+ case TURN_DETECTION -> {
+ if (SpeechOutputParamTurnDetection.EACH_CALL_IS_A_TURN.equals(param)) {
+ turnDetectionEager = true;
+ } else if (SpeechOutputParamTurnDetection.BY_MODEL_AUTO.equals(param)) {
+ turnDetectionEager = false;
+ }
+ }
+ }
+ }
+ this.voice = voice;
+ this.eagerTurnDetection = turnDetectionEager;
+ }
+
+ public void inputAudio(byte[] rawAudioChunk) {
+ if (rawAudioChunk.length == 0) {
+ return;
+ }
+ var cursorLeft = 0;
+ while (cursorLeft < rawAudioChunk.length) {
+ var cursorRight = Math.min(cursorLeft + MAX_DATA_CHUNK_SIZE_BYTES, rawAudioChunk.length);
+ var part = Arrays.copyOfRange(rawAudioChunk, cursorLeft, cursorRight);
+ var audioInputMessage =
+ InputAudioBufferAppendEvent.builder()
+ .audio(Base64.getEncoder().encodeToString(part))
+ .build();
+ super.sendMessage(audioInputMessage);
+ cursorLeft += MAX_DATA_CHUNK_SIZE_BYTES;
+ }
+
+ if (eagerTurnDetection) {
+ var commitAudioMessage = InputAudioBufferCommitEvent.builder().build();
+ super.sendMessage(commitAudioMessage);
+ askForResponse();
+ }
+ }
+
+ @Override
+ protected String getSystemPrompt() {
+ return TASK;
+ }
+
+ @Override
+ protected void onResponse(String eventType, JsonNode event) {
+ if ("response.output_audio.delta".equals(eventType)) {
+ var base64Audio = event.get("delta").asText();
+ byte[] audio = Base64.getDecoder().decode(base64Audio);
+ this.outputConsumer.outputAudio(audio, Boolean.FALSE);
+ } else if ("response.output_audio.done".equals(eventType)) {
+ this.outputConsumer.outputAudio(EMPTY_BYTE_ARRAY, Boolean.TRUE);
+ }
+ }
+
+ @Override
+ protected SessionUpdateEvent sessionConfiguration() {
+ RealtimeAudioInputTurnDetection turnDetection;
+ if (eagerTurnDetection) {
+ turnDetection = null;
+ } else {
+ turnDetection =
+ RealtimeAudioInputTurnDetection.ofSemanticVad(
+ RealtimeAudioInputTurnDetection.SemanticVad.builder().build());
+ }
+
+ return SessionUpdateEvent.builder()
+ .session(
+ ClientSecretCreateParams.Session.ofRealtime(
+ RealtimeSessionCreateRequest.builder()
+ .outputModalities(OUTPUT_MODALITIES)
+ .audio(
+ RealtimeAudioConfig.builder()
+ .input(
+ RealtimeAudioConfigInput.builder()
+ .turnDetection(turnDetection)
+ .format(
+ RealtimeAudioFormats.AudioPcm.builder()
+ .type(RealtimeAudioFormats.AudioPcm.Type.AUDIO_PCM)
+ .rate(RealtimeAudioFormats.AudioPcm.Rate._24000)
+ .build())
+ .build())
+ .output(
+ RealtimeAudioConfigOutput.builder()
+ .format(
+ RealtimeAudioFormats.AudioPcm.builder()
+ .type(RealtimeAudioFormats.AudioPcm.Type.AUDIO_PCM)
+ .rate(RealtimeAudioFormats.AudioPcm.Rate._24000)
+ .build())
+ .voice(voice)
+ .build())
+ .build())
+ .build())
+ .asRealtime())
+ .build();
+ }
+}
diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/SpeechToTextRealtimeClient.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/SpeechToTextRealtimeClient.java
new file mode 100644
index 000000000..a6a606da3
--- /dev/null
+++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/SpeechToTextRealtimeClient.java
@@ -0,0 +1,94 @@
+package com.sap.ai.sdk.foundationmodels.openai;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.openai.models.realtime.*;
+import com.openai.models.realtime.clientsecrets.ClientSecretCreateParams;
+import java.util.*;
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
+class SpeechToTextRealtimeClient extends WSSOpenAIRealtimeClient implements AudioInputChannel {
+
+ private static final int MAX_DATA_CHUNK_SIZE_BYTES = 8192;
+ private static final Set HANDLED_RESPONSE_TYPES =
+ Set.of("response.output_audio_transcript.done");
+
+ private static final String TASK =
+ "you are a transcript writer and your role is to convert input speech to text as precise as possible (word to word exactly, if possible)";
+
+ private final TextOutputChannel outputConsumer;
+
+ public SpeechToTextRealtimeClient(
+ String url, Map httpHeaders, TextOutputChannel outputConsumer) {
+ super(url, httpHeaders, HANDLED_RESPONSE_TYPES);
+ this.outputConsumer = outputConsumer;
+ }
+
+ public void inputAudio(byte[] rawAudioChunk) {
+ if (rawAudioChunk.length == 0) {
+ return;
+ }
+ var cursorLeft = 0;
+ while (cursorLeft < rawAudioChunk.length) {
+ var cursorRight = Math.min(cursorLeft + MAX_DATA_CHUNK_SIZE_BYTES, rawAudioChunk.length);
+ var part = Arrays.copyOfRange(rawAudioChunk, cursorLeft, cursorRight);
+ var audioInputMessage =
+ InputAudioBufferAppendEvent.builder()
+ .audio(Base64.getEncoder().encodeToString(part))
+ .build();
+ super.sendMessage(audioInputMessage);
+ cursorLeft += MAX_DATA_CHUNK_SIZE_BYTES;
+ }
+
+ var commitAudioMessage = InputAudioBufferCommitEvent.builder().build();
+ super.sendMessage(commitAudioMessage);
+ askForResponse();
+ }
+
+ @Override
+ protected String getSystemPrompt() {
+ return TASK;
+ }
+
+ @Override
+ protected void onResponse(String eventType, JsonNode event) {
+ if ("response.output_audio_transcript.done".equals(eventType)) {
+ this.outputConsumer.outputText(
+ event.has("transcript") ? event.get("transcript").asText() : "", Boolean.TRUE);
+ } else {
+ log.trace("skipping message type: {}", eventType);
+ }
+ }
+
+ @Override
+ protected SessionUpdateEvent sessionConfiguration() {
+ SessionUpdateEvent sessionUpdateEvent =
+ SessionUpdateEvent.builder()
+ .session(
+ ClientSecretCreateParams.Session.ofTranscription(
+ RealtimeTranscriptionSessionCreateRequest.builder()
+ .audio(
+ RealtimeTranscriptionSessionAudio.builder()
+ .input(
+ RealtimeTranscriptionSessionAudioInput.builder()
+ .turnDetection(Optional.empty())
+ .format(
+ RealtimeAudioFormats.AudioPcm.builder()
+ .type(
+ RealtimeAudioFormats.AudioPcm.Type
+ .AUDIO_PCM)
+ .rate(RealtimeAudioFormats.AudioPcm.Rate._24000)
+ .build())
+ .transcription(
+ AudioTranscription.builder()
+ .model(
+ AudioTranscription.Model.GPT_4O_TRANSCRIBE)
+ .build())
+ .build())
+ .build())
+ .build())
+ .asTranscription())
+ .build();
+ return sessionUpdateEvent;
+ }
+}
diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/TextInputChannel.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/TextInputChannel.java
new file mode 100644
index 000000000..e1cfe32db
--- /dev/null
+++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/TextInputChannel.java
@@ -0,0 +1,5 @@
+package com.sap.ai.sdk.foundationmodels.openai;
+
+public interface TextInputChannel extends AutoCloseable {
+ void sendText(String text);
+}
diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/TextOutputChannel.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/TextOutputChannel.java
new file mode 100644
index 000000000..ff482d553
--- /dev/null
+++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/TextOutputChannel.java
@@ -0,0 +1,20 @@
+package com.sap.ai.sdk.foundationmodels.openai;
+
+/**
+ * Functional interface representing test output channel (text data consumer)
+ *
+ * Should be closed by application (try-with-resources) when not needed anymore
+ */
+public interface TextOutputChannel {
+
+ /**
+ * This method is sequentially invoked by test data provider to supply implementer (consumer) with
+ * the test data.
+ *
+ * @param textChunk chunk of the text (possibly partial content)
+ * @param isLast true if this call logically concludes previous and this passed text data into a
+ * single logical entity (e.g. gets called at the end when all parts of a single message get
+ * passed)
+ */
+ void outputText(CharSequence textChunk, boolean isLast);
+}
diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/TextToSpeechRealtimeClient.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/TextToSpeechRealtimeClient.java
new file mode 100644
index 000000000..a94b957c5
--- /dev/null
+++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/TextToSpeechRealtimeClient.java
@@ -0,0 +1,137 @@
+package com.sap.ai.sdk.foundationmodels.openai;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.openai.models.realtime.*;
+import com.openai.models.realtime.clientsecrets.ClientSecretCreateParams;
+import com.sap.ai.sdk.core.model.SpeechOutputParam;
+import com.sap.ai.sdk.core.model.SpeechOutputParamTurnDetection;
+import com.sap.ai.sdk.core.model.SpeechOutputParamVoice;
+import java.util.*;
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
+class TextToSpeechRealtimeClient extends WSSOpenAIRealtimeClient implements TextInputChannel {
+
+ private static final byte[] EMPTY_BYTE_ARRAY = new byte[0];
+
+ private static final Set HANDLED_RESPONSE_TYPES =
+ Set.of("response.output_audio.delta", "response.output_audio.done");
+
+ private static final List OUTPUT_MODALITIES =
+ List.of(RealtimeSessionCreateRequest.OutputModality.AUDIO);
+
+ private static final String TASK =
+ "you are a speaker and your role is to read (produce audio) of the user input speech. voice user text input";
+
+ private final AudioOutputChannel outputConsumer;
+ private final RealtimeAudioConfigOutput.Voice.UnionMember1 voice;
+ private final boolean eagerTurnDetection;
+
+ public TextToSpeechRealtimeClient(
+ String url,
+ Map httpHeaders,
+ AudioOutputChannel outputConsumer,
+ SpeechOutputParam... params) {
+ super(url, httpHeaders, HANDLED_RESPONSE_TYPES);
+ this.outputConsumer = outputConsumer;
+ var voice = RealtimeAudioConfigOutput.Voice.UnionMember1.MARIN;
+ var turnDetectionEager = true;
+ for (SpeechOutputParam param : params) {
+ switch (param.getParamName()) {
+ case VOICE -> {
+ if (SpeechOutputParamVoice.DEFAULT_WOMAN.equals(param)) {
+ voice = RealtimeAudioConfigOutput.Voice.UnionMember1.MARIN;
+ } else if (SpeechOutputParamVoice.DEFAULT_MAN.equals(param)) {
+ voice = RealtimeAudioConfigOutput.Voice.UnionMember1.ECHO;
+ }
+ }
+ case TURN_DETECTION -> {
+ if (SpeechOutputParamTurnDetection.EACH_CALL_IS_A_TURN.equals(param)) {
+ turnDetectionEager = true;
+ } else if (SpeechOutputParamTurnDetection.BY_MODEL_AUTO.equals(param)) {
+ turnDetectionEager = false;
+ }
+ }
+ }
+ }
+ this.voice = voice;
+ this.eagerTurnDetection = turnDetectionEager;
+ }
+
+ public void sendText(String text) {
+ var message =
+ ConversationItemCreateEvent.builder()
+ .item(
+ ConversationItem.ofRealtimeConversationItemUserMessage(
+ RealtimeConversationItemUserMessage.builder()
+ .addContent(
+ RealtimeConversationItemUserMessage.Content.builder()
+ .text(text)
+ .type(RealtimeConversationItemUserMessage.Content.Type.INPUT_TEXT)
+ .build())
+ .build()))
+ .build();
+
+ super.sendMessage(message);
+ if (eagerTurnDetection) {
+ askForResponse();
+ }
+ }
+
+ @Override
+ protected String getSystemPrompt() {
+ return TASK;
+ }
+
+ @Override
+ protected void onResponse(String eventType, JsonNode event) {
+ if ("response.output_audio.delta".equals(eventType)) {
+ var base64Audio = event.get("delta").asText();
+ byte[] audio = Base64.getDecoder().decode(base64Audio);
+ this.outputConsumer.outputAudio(audio, Boolean.FALSE);
+ } else if ("response.output_audio.done".equals(eventType)) {
+ this.outputConsumer.outputAudio(EMPTY_BYTE_ARRAY, Boolean.TRUE);
+ } else {
+ log.warn("skipping message type: {}", eventType);
+ }
+ }
+
+ @Override
+ protected SessionUpdateEvent sessionConfiguration() {
+ SessionUpdateEvent sessionUpdateEvent =
+ SessionUpdateEvent.builder()
+ .session(
+ ClientSecretCreateParams.Session.ofRealtime(
+ RealtimeSessionCreateRequest.builder()
+ .outputModalities(OUTPUT_MODALITIES)
+ .audio(
+ RealtimeAudioConfig.builder()
+ .input(
+ RealtimeAudioConfigInput.builder()
+ .turnDetection(Optional.empty())
+ .format(
+ RealtimeAudioFormats.AudioPcm.builder()
+ .type(
+ RealtimeAudioFormats.AudioPcm.Type
+ .AUDIO_PCM)
+ .rate(RealtimeAudioFormats.AudioPcm.Rate._24000)
+ .build())
+ .build())
+ .output(
+ RealtimeAudioConfigOutput.builder()
+ .format(
+ RealtimeAudioFormats.AudioPcm.builder()
+ .type(
+ RealtimeAudioFormats.AudioPcm.Type
+ .AUDIO_PCM)
+ .rate(RealtimeAudioFormats.AudioPcm.Rate._24000)
+ .build())
+ .voice(voice)
+ .build())
+ .build())
+ .build())
+ .asRealtime())
+ .build();
+ return sessionUpdateEvent;
+ }
+}
diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/WSSOpenAIRealtimeClient.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/WSSOpenAIRealtimeClient.java
new file mode 100644
index 000000000..a78f66d83
--- /dev/null
+++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/WSSOpenAIRealtimeClient.java
@@ -0,0 +1,187 @@
+package com.sap.ai.sdk.foundationmodels.openai;
+
+import com.fasterxml.jackson.annotation.JsonAutoDetect;
+import com.fasterxml.jackson.annotation.PropertyAccessor;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.openai.models.realtime.*;
+import com.sap.ai.sdk.core.common.ClientException;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.WebSocket;
+import java.nio.ByteBuffer;
+import java.util.*;
+import java.util.concurrent.CompletableFuture;
+import lombok.AccessLevel;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
+@RequiredArgsConstructor(access = AccessLevel.PACKAGE)
+abstract class WSSOpenAIRealtimeClient implements AutoCloseable {
+
+ private static final int SUCCESS_FINISH_WSS_CODE = 1000;
+ private static final ObjectMapper JACKSON =
+ new ObjectMapper().setVisibility(PropertyAccessor.IS_GETTER, JsonAutoDetect.Visibility.NONE);
+
+ /**
+ * WebSocket keeps TCP connection to the server. If connection idles, depending on the provider in
+ * cloud environments, cloud provider sends hard TCP RST (reset) after 60-300 seconds of
+ * inactivity, this is normally not configurable and would leave realtime conversation unusable on
+ * a pause.
+ *
+ * In mobile networks this period is even shorter and typical CGNAT session can only idle for
+ * 10-15 seconds before it gets terminated
+ *
+ *
In realtime client we rely on explicit lifetime management (create/close), heartbeat
+ * mechanism is required to prevent unintended closing of connections while conversation is still
+ * expected to continue
+ */
+ private static final int HEARTBEAT_INTERVAL_MILLIS = 4500;
+
+ private static final String HEARTBEAT_TIMER_NAME = "wss_realtime_heartbeat";
+
+ private final HttpClient client;
+ private final CompletableFuture ws;
+ private final Timer heartbeatTimer;
+ private final Set handleMessageTypes;
+
+ public WSSOpenAIRealtimeClient(
+ String url, Map httpHeaders, Set handleMessageTypes) {
+ this.client = HttpClient.newHttpClient();
+ var wsBuilder = this.client.newWebSocketBuilder();
+ for (Map.Entry entry : httpHeaders.entrySet()) {
+ wsBuilder = wsBuilder.header(entry.getKey(), entry.getValue());
+ }
+ this.ws =
+ wsBuilder.buildAsync(
+ URI.create(url), new BufferedWebSocketListener(this::onSocketOpen, this::onText));
+ this.handleMessageTypes = handleMessageTypes;
+ this.heartbeatTimer = new Timer(HEARTBEAT_TIMER_NAME, true);
+ }
+
+ public void askForResponse() {
+ WebSocket ws = this.ws.join();
+ synchronized (this) {
+ try {
+ ws.sendText(JACKSON.writeValueAsString(ResponseCreateEvent.builder().build()), true);
+ } catch (JsonProcessingException e) {
+ throw new RuntimeException("Failed to serialize ask for response", e);
+ }
+ ws.request(1);
+ }
+ }
+
+ @Override
+ public void close() {
+ WebSocket ws = this.ws.join();
+ synchronized (this) {
+ heartbeatTimer.cancel();
+ try {
+ ws.sendClose(SUCCESS_FINISH_WSS_CODE, "done").join();
+ } catch (Exception e) {
+ log.error("Error while closing WebSocket", e);
+ }
+ // this.client.close(); // exists only since java 21
+ }
+ }
+
+ protected abstract String getSystemPrompt();
+
+ protected abstract void onResponse(String eventType, JsonNode event);
+
+ protected abstract SessionUpdateEvent sessionConfiguration();
+
+ protected void sendMessage(Object message) {
+ WebSocket ws = this.ws.join();
+ synchronized (this) {
+ try {
+ ws.sendText(JACKSON.writeValueAsString(message), true);
+ ws.request(1);
+ } catch (JsonProcessingException e) {
+ throw new ClientException("Failed to serialize message", e);
+ }
+ }
+ }
+
+ private synchronized void onSocketOpen(WebSocket ws) {
+ configureSession(ws);
+ configureConversation(ws);
+ scheduleHeartbeat(ws);
+ }
+
+ private void onText(WebSocket webSocket, CharSequence data) {
+ final JsonNode event;
+ try {
+ event = JACKSON.readTree(data.toString());
+ } catch (JsonProcessingException e) {
+ throw new ClientException("Error parsing JSON response from speech API", e);
+ }
+ var eventType = event.get("type").asText();
+ if (handleMessageTypes.contains(eventType)) {
+ onResponse(eventType, event);
+ } else {
+ log.trace("Unhandled event type: {}", eventType);
+ }
+
+ webSocket.request(1);
+ }
+
+ private synchronized void sendPing(WebSocket ws) {
+ if (ws.isInputClosed()) {
+ return;
+ }
+ ws.sendPing(ByteBuffer.wrap("ping".getBytes())).join();
+ ws.request(1);
+ }
+
+ private void configureSession(WebSocket ws) {
+ SessionUpdateEvent sue = sessionConfiguration();
+ try {
+ ws.sendText(JACKSON.writeValueAsString(sue), true);
+ } catch (JsonProcessingException e) {
+ throw new ClientException("Failed to serialize session request", e);
+ }
+
+ ws.request(1);
+ }
+
+ private void configureConversation(WebSocket ws) {
+ var systemPrompt = getSystemPrompt();
+ if (systemPrompt == null || systemPrompt.isEmpty()) {
+ return;
+ }
+ var systemConversationItem =
+ ConversationItemCreateEvent.builder()
+ .item(
+ ConversationItem.ofRealtimeConversationItemSystemMessage(
+ RealtimeConversationItemSystemMessage.builder()
+ .addContent(
+ RealtimeConversationItemSystemMessage.Content.builder()
+ .text(systemPrompt)
+ .type(RealtimeConversationItemSystemMessage.Content.Type.INPUT_TEXT)
+ .build())
+ .build()))
+ .build();
+
+ try {
+ var json = JACKSON.writeValueAsString(systemConversationItem);
+ ws.sendText(json, true);
+ } catch (JsonProcessingException e) {
+ throw new ClientException("Failed to serialize message", e);
+ }
+ ws.request(1);
+ }
+
+ private void scheduleHeartbeat(WebSocket ws) {
+ TimerTask task =
+ new TimerTask() {
+ @Override
+ public void run() {
+ sendPing(ws);
+ }
+ };
+ heartbeatTimer.scheduleAtFixedRate(task, 0, HEARTBEAT_INTERVAL_MILLIS);
+ }
+}
diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationAiModel.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationAiModel.java
index 1345deaba..15058cc5e 100644
--- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationAiModel.java
+++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationAiModel.java
@@ -346,6 +346,9 @@ public class OrchestrationAiModel {
/** Azure OpenAI GPT-5-mini model */
public static final OrchestrationAiModel GPT_5_MINI = new OrchestrationAiModel("gpt-5-mini");
+ /** Azure OpenAI GPT-realtime model */
+ public static final OrchestrationAiModel GPT_REALTIME = new OrchestrationAiModel("gpt-realtime");
+
/** Azure OpenAI GPT-5-nano model */
public static final OrchestrationAiModel GPT_5_NANO = new OrchestrationAiModel("gpt-5-nano");
diff --git a/pom.xml b/pom.xml
index b41255495..05c8e5563 100644
--- a/pom.xml
+++ b/pom.xml
@@ -66,6 +66,7 @@
2.1.3
3.5.6
1.1.8
+ 4.41.0
3.8.6
3.2.0
5.23.0
@@ -119,6 +120,11 @@
pom
import
+