Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
6f303a3
add realtime text to speech client, WIP
vladimir-a-sap Jul 7, 2026
ecd953e
polish minimap text-to-speech realtime poc
vladimir-a-sap Jul 7, 2026
71d4a4c
add incomplete interim packets buffering and glueing
vladimir-a-sap Jul 7, 2026
232725d
remove obsolete debug constants
vladimir-a-sap Jul 7, 2026
6b872d4
Formatting
bot-sdk-js Jul 7, 2026
54b06a5
add speech to speech realtime support and text to speech example
vladimir-a-sap Jul 10, 2026
df2098a
merge changes from upstream
vladimir-a-sap Jul 10, 2026
80a5d3e
add speech to speech demo scenario
vladimir-a-sap Jul 10, 2026
d6ad031
add smoke test for realtime api and configurability
vladimir-a-sap Jul 13, 2026
2ed77e0
rework sample application so it no longer use deprecated browser micr…
vladimir-a-sap Jul 13, 2026
c212c1e
make realtime implementation package local
vladimir-a-sap Jul 13, 2026
d144a3a
Merge branch 'main' into feat/poc-openai-realtime-api
vladimir-a-sap Jul 13, 2026
03d1e70
Formatting
bot-sdk-js Jul 13, 2026
5415795
address pr feedback, add tests
vladimir-a-sap Jul 17, 2026
a0cd474
Formatting
bot-sdk-js Jul 17, 2026
25d673b
Merge branch 'main' into feat/poc-openai-realtime-api
vladimir-a-sap Jul 17, 2026
8dc8ae6
remove duplicated dependency
vladimir-a-sap Jul 17, 2026
4240910
improve code style
vladimir-a-sap Jul 17, 2026
0603218
improve code style
vladimir-a-sap Jul 17, 2026
fcebe1e
Formatting
bot-sdk-js Jul 17, 2026
9a6d251
extract common realtime api functionality to a superclass
vladimir-a-sap Jul 17, 2026
de69739
Merge branch 'feat/poc-openai-realtime-api' of github.com:SAP/ai-sdk-…
vladimir-a-sap Jul 17, 2026
0f2cd62
Formatting
bot-sdk-js Jul 17, 2026
6082105
fix misleading javadocs
vladimir-a-sap Jul 17, 2026
0c2038d
Merge branch 'feat/poc-openai-realtime-api' of github.com:SAP/ai-sdk-…
vladimir-a-sap Jul 17, 2026
537203d
Formatting
bot-sdk-js Jul 17, 2026
7a5220e
replace debug output in test with actual assertions
vladimir-a-sap Jul 17, 2026
e7d844d
fix annotations from invalid package
vladimir-a-sap Jul 20, 2026
7c6dced
Formatting
bot-sdk-js Jul 20, 2026
eb8a451
Merge branch 'main' into feat/poc-openai-realtime-api
vladimir-a-sap Jul 20, 2026
671f960
add missing javadoc comments
vladimir-a-sap Jul 20, 2026
625f72a
Merge branch 'feat/poc-openai-realtime-api' of github.com:SAP/ai-sdk-…
vladimir-a-sap Jul 20, 2026
b4ba471
Formatting
bot-sdk-js Jul 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions core/src/main/java/com/sap/ai/sdk/core/RealtimeParam.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.sap.ai.sdk.core;

import javax.annotation.Nonnull;

/** Represents possible configuration params of realtime client */
public interface RealtimeParam {
enum SpeechOutputParamName {
VOICE,
TURN_DETECTION,
}

/**
* Returns param name
*
* @return name
*/
@Nonnull
SpeechOutputParamName getParamName();

/**
* Returns string value representation of the param
*
* @return string value
*/
@Nonnull
String getValueAsString();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package com.sap.ai.sdk.core;

import java.util.Objects;
import javax.annotation.Nonnull;

/** Allows to configure turn detection (how model responds). */
public final class RealtimeParamTurnDetection implements RealtimeParam {

/** Model tries to recognize if/when it should respond automatically */
public static final RealtimeParamTurnDetection BY_MODEL_AUTO =
new RealtimeParamTurnDetection("BY_MODEL_AUTO");

/**
* Each call to the provided realtime client is considered a turn (eager explicit turn detection).
* Less convenient than the automatic option but may give lower latency in some cases (model does
* not need to perform additional turn detection analysis).
*/
public static final RealtimeParamTurnDetection EACH_CALL_IS_A_TURN =
new RealtimeParamTurnDetection("EACH_CALL_IS_A_TURN");

private final String turnDetectionKind;

private RealtimeParamTurnDetection(String turnDetectionKind) {
this.turnDetectionKind = turnDetectionKind;
}

@Override
public @Nonnull SpeechOutputParamName getParamName() {
return SpeechOutputParamName.TURN_DETECTION;
}

@Override
public @Nonnull String getValueAsString() {
return turnDetectionKind;
}

@Override
public boolean equals(Object o) {
if (o == null || getClass() != o.getClass()) return false;
RealtimeParamTurnDetection that = (RealtimeParamTurnDetection) o;
return Objects.equals(turnDetectionKind, that.turnDetectionKind);
}

@Override
public int hashCode() {
return Objects.hashCode(turnDetectionKind);
}
}
53 changes: 53 additions & 0 deletions core/src/main/java/com/sap/ai/sdk/core/RealtimeParamVoice.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package com.sap.ai.sdk.core;

import java.util.Objects;
import javax.annotation.Nonnull;

/** Allows to configure model output voice */
public final class RealtimeParamVoice implements RealtimeParam {

/** Standard voice 1 */
public static final RealtimeParamVoice DEFAULT_1 = new RealtimeParamVoice("DEFAULT_1");

/** Standard voice 2 */
public static final RealtimeParamVoice DEFAULT_2 = new RealtimeParamVoice("DEFAULT_2");

private final String voice;

private RealtimeParamVoice(@Nonnull String voice) {
this.voice = voice;
}

/**
* Allows to configure raw voice name as named by model provider. Unsafe because SDK cannot verify
* in advance if the provided voice name is correct and supported by the chosen model and use case
*
* @param voiceName as named by model provider
* @return typed voice client configuration param
*/
public static RealtimeParamVoice unsafeWithExplicitVoice(String voiceName) {
return new RealtimeParamVoice(voiceName);
}

@Override
public @Nonnull SpeechOutputParamName getParamName() {
return SpeechOutputParamName.VOICE;
}

@Override
public @Nonnull String getValueAsString() {
return voice;
}

@Override
public boolean equals(Object o) {
if (o == null || getClass() != o.getClass()) return false;
RealtimeParamVoice that = (RealtimeParamVoice) o;
return Objects.equals(voice, that.voice);
}

@Override
public int hashCode() {
return Objects.hashCode(voice);
}
}
4 changes: 4 additions & 0 deletions foundation-models/openai/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@
<groupId>com.sap.ai.sdk</groupId>
<artifactId>core</artifactId>
</dependency>
<dependency>
<groupId>com.openai</groupId>
<artifactId>openai-java</artifactId>
</dependency>
<dependency>
<groupId>com.google.code.findbugs</groupId>
<artifactId>jsr305</artifactId>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.sap.ai.sdk.foundationmodels.openai;

/**
* Functional interface representing audio input channel (audio data consumer)
*
* <p>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);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.sap.ai.sdk.foundationmodels.openai;

/** Functional interface representing audio output channel (audio data consumer) */
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);
}
Original file line number Diff line number Diff line change
@@ -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
class BufferedWebSocketListener implements WebSocket.Listener {

private final Consumer<WebSocket> onOpen;
private final BiConsumer<WebSocket, CharSequence> onText;
private final StringBuilder buffer;

BufferedWebSocketListener(
Consumer<WebSocket> onOpen, BiConsumer<WebSocket, CharSequence> 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 isLast) {
buffer.append(data);
webSocket.request(1);
if (isLast) {
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 isLast) {
log.warn("Received unexpected binary bytes for WebSocket connection");
return CompletableFuture.completedStage(null);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,17 @@ public static OpenAiClient forModel(@Nonnull final OpenAiModel foundationModel)
return client.withApiVersion(DEFAULT_API_VERSION);
}

/**
* Creates and configures OpenAI Realtime API client
*
* @return created client
*/
@Beta
public static OpenAiRealtimeClient realtimeClient() {
var withResolvedDestination = OpenAiClient.forModel(OpenAiModel.GPT_REALTIME);
return new OpenAiRealtimeClient(withResolvedDestination.destination);
}

/**
* Create a new OpenAI client targeting the specified API version.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package com.sap.ai.sdk.foundationmodels.openai;

import com.google.common.annotations.Beta;
import com.sap.ai.sdk.core.RealtimeParam;
import com.sap.cloud.sdk.cloudplatform.connectivity.Destination;
import com.sap.cloud.sdk.cloudplatform.connectivity.Header;
import java.util.HashMap;
import javax.annotation.Nonnull;

public class OpenAiRealtimeClient {

private static final int PATH_BUFFER_SIZE =
400; // existing URLs are ~120 symbols long, 400 has reasonable margin

private final Destination destination;

OpenAiRealtimeClient(Destination destination) {
this.destination = destination;
}

/**
* Creates realtime channel allowing to input text and voice it (receive audio output)
*
* <p>The input channel should be used with a try-with-resources block to ensure that the
* underlying connection is closed.
*
* <p>Example:
*
* <pre>{@code
* try (var textInputChannel = client.textToSpeech(audioOutputConsumer)) {
* textInputChannel.sendText("...");
* ....
* }
* }</pre>
*
* 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, 16
* bit depth
* @return input channel, allowing for text input
*/
@Nonnull
@Beta
public TextInputChannel textToSpeech(
@Nonnull final AudioOutputChannel audioOutputConsumer, RealtimeParam... params) {
var extraHeaders = destination.asHttp().getHeaders();
var headers = new HashMap<String, String>(extraHeaders.size() + 1);
for (Header header : extraHeaders) {
headers.put(header.getName(), header.getValue());
}
var endpoint = getRealtimeEndpoint();

return new TextToSpeechRealtimeClient(endpoint, headers, audioOutputConsumer, params);
}

/**
* Creates realtime channel allowing for audio conversation with a model
*
* <p>The input channel should be used with a try-with-resources block to ensure that the
* underlying connection is closed.
*
* <p>Example:
*
* <pre>{@code
* try (var audioInputChannel = client.speechToSpeech(audioOutputConsumer)) {
* audioInputChannel.inputAudio(audioBytesData);
* ....
* }
* }</pre>
*
* 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, 16
* bit depth
* @param params - optional configuration params
* @return input channel, allowing for audio data input (bytes, PCM mono 24000 Hz little endian 16
* bit)
*/
@Nonnull
@Beta
public AudioInputChannel speechToSpeech(
@Nonnull final AudioOutputChannel audioOutputConsumer, RealtimeParam... params) {
var extraHeaders = destination.asHttp().getHeaders();
var headers = new HashMap<String, String>(extraHeaders.size() + 1);
for (Header header : extraHeaders) {
headers.put(header.getName(), header.getValue());
}
var endpoint = getRealtimeEndpoint();

return new SpeechToSpeechRealtimeClient(endpoint, headers, audioOutputConsumer, 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();
}
}
Loading