Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.sap.ai.sdk.core.model;

import javax.annotation.Nonnull;

public interface SpeechOutputParam {
enum SpeechOutputParamName {
VOICE,
TURN_DETECTION,
}

@Nonnull
SpeechOutputParamName getParamName();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.sap.ai.sdk.core.model;

import java.util.Objects;
import org.jspecify.annotations.NonNull;

public final class SpeechOutputParamTurnDetection implements SpeechOutputParam {

public static final SpeechOutputParamTurnDetection BY_MODEL_AUTO =
new SpeechOutputParamTurnDetection("BY_MODEL_AUTO");
public static final SpeechOutputParamTurnDetection EACH_CALL_IS_A_TURN =
new SpeechOutputParamTurnDetection("EACH_CALL_IS_A_TURN");

private final String value;

private SpeechOutputParamTurnDetection(String value) {
this.value = value;
}

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

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

@Override
public int hashCode() {
return Objects.hashCode(value);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.sap.ai.sdk.core.model;

import java.util.Objects;
import javax.annotation.Nonnull;
import org.jspecify.annotations.NonNull;

public final class SpeechOutputParamVoice implements SpeechOutputParam {

public static final SpeechOutputParamVoice DEFAULT_MAN =
new SpeechOutputParamVoice("DEFAULT_MAN");
public static final SpeechOutputParamVoice DEFAULT_WOMAN =
new SpeechOutputParamVoice("DEFAULT_WOMAN");

private final String voice;

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

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

@Override
public boolean equals(Object o) {
if (o == null || getClass() != o.getClass()) return false;
SpeechOutputParamVoice that = (SpeechOutputParamVoice) 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,21 @@
package com.sap.ai.sdk.foundationmodels.openai;

/**
* Functional interface representing audio output channel (audio data consumer)
*
* <p>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);
}
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
public class BufferedWebSocketListener implements WebSocket.Listener {

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

public 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 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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();

Expand Down Expand Up @@ -260,6 +264,80 @@ public Stream<String> streamChatCompletion(@Nonnull final String prompt)
.map(OpenAiChatCompletionDelta::getDeltaContent);
}

/**
* 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
* @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<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);
}

// unstable, requires further debugging and testing
AudioInputChannel speechToText(@Nonnull final TextOutputChannel textOutputConsumer) {
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() + "?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<String, String>(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")) {
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
Loading
Loading