org.projectlombok
@@ -202,13 +211,13 @@
/deployments/{deployment-id}/completions
- /deployments/{deployment-id}/audio/transcriptions
- /deployments/{deployment-id}/audio/translations
- /deployments/{deployment-id}/images/generations
+ /deployments/{deployment-id}/audio/transcriptions
+ /deployments/{deployment-id}/audio/translations
+ /deployments/{deployment-id}/images/generations
chatCompletionResponseMessage.context
- createChatCompletionRequest.data_sources
+ createChatCompletionRequest.data_sources
true
diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/AiCoreOpenAiClient.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/AiCoreOpenAiClient.java
new file mode 100644
index 000000000..9bf5ff4d3
--- /dev/null
+++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/AiCoreOpenAiClient.java
@@ -0,0 +1,319 @@
+package com.sap.ai.sdk.foundationmodels.openai;
+
+import com.google.common.annotations.Beta;
+import com.openai.client.OpenAIClient;
+import com.openai.client.OpenAIClientImpl;
+import com.openai.core.ClientOptions;
+import com.openai.core.RequestOptions;
+import com.openai.core.http.Headers;
+import com.openai.core.http.HttpClient;
+import com.openai.core.http.HttpRequest;
+import com.openai.core.http.HttpResponse;
+import com.openai.errors.OpenAIIoException;
+import com.sap.ai.sdk.core.AiCoreService;
+import com.sap.ai.sdk.core.AiModel;
+import com.sap.ai.sdk.core.DeploymentResolutionException;
+import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Accessor;
+import com.sap.cloud.sdk.cloudplatform.connectivity.HttpDestination;
+import com.sap.cloud.sdk.cloudplatform.thread.ThreadContextExecutors;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.Locale;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+import javax.annotation.Nonnull;
+import lombok.AccessLevel;
+import lombok.NoArgsConstructor;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.hc.core5.http.ClassicHttpRequest;
+import org.apache.hc.core5.http.ClassicHttpResponse;
+import org.apache.hc.core5.http.ContentType;
+import org.apache.hc.core5.http.io.entity.ByteArrayEntity;
+import org.apache.hc.core5.http.io.entity.EntityUtils;
+import org.apache.hc.core5.http.message.BasicClassicHttpRequest;
+import org.apache.hc.core5.net.URIBuilder;
+
+/**
+ * Factory for creating OpenAI SDK clients configured for SAP AI Core deployments.
+ *
+ * This class provides factory methods that return fully configured OpenAI SDK clients using SAP
+ * Cloud SDK's Apache HttpClient with automatic OAuth token refresh.
+ *
+ * @since 1.18.0
+ */
+@Slf4j
+@NoArgsConstructor(access = AccessLevel.PRIVATE)
+@Beta
+public final class AiCoreOpenAiClient {
+
+ private static final String DEFAULT_RESOURCE_GROUP = "default";
+
+ /**
+ * Create an OpenAI client for a deployment serving the specified model using the default resource
+ * group.
+ *
+ * @param model The AI model to target.
+ * @return A configured OpenAI client instance.
+ * @throws DeploymentResolutionException If no running deployment is found for the model.
+ */
+ @Nonnull
+ public static OpenAIClient forModel(@Nonnull final AiModel model) {
+ return forModel(model, DEFAULT_RESOURCE_GROUP);
+ }
+
+ /**
+ * Create an OpenAI client for a deployment serving the specified model in the given resource
+ * group.
+ *
+ * @param model The AI model to target.
+ * @param resourceGroup The resource group containing the deployment.
+ * @return A configured OpenAI client instance.
+ * @throws DeploymentResolutionException If no running deployment is found for the model.
+ */
+ @Nonnull
+ public static OpenAIClient forModel(
+ @Nonnull final AiModel model, @Nonnull final String resourceGroup) {
+ final HttpDestination destination =
+ new AiCoreService().getInferenceDestination(resourceGroup).forModel(model);
+
+ return fromDestination(destination);
+ }
+
+ /**
+ * Create an OpenAI client from a pre-resolved destination.
+ *
+ * @param destination The destination to use.
+ * @return A configured OpenAI client instance.
+ */
+ @Nonnull
+ @SuppressWarnings("PMD.CloseResource")
+ static OpenAIClient fromDestination(@Nonnull final HttpDestination destination) {
+ final var baseUrl = destination.getUri().toString();
+ final var httpClient = new AiCoreHttpClientImpl(destination);
+
+ final ClientOptions clientOptions =
+ ClientOptions.builder().baseUrl(baseUrl).httpClient(httpClient).apiKey("unused").build();
+
+ return new OpenAIClientImpl(clientOptions);
+ }
+
+ /**
+ * Internal implementation of OpenAI SDK's HttpClient interface using Apache HttpClient from SAP
+ * Cloud SDK.
+ */
+ @Slf4j
+ @RequiredArgsConstructor(access = AccessLevel.PACKAGE)
+ static final class AiCoreHttpClientImpl implements HttpClient {
+ private final HttpDestination destination;
+
+ private static final String SSE_MEDIA_TYPE = "text/event-stream";
+ private static final Set ALLOWED_PATHS =
+ Set.of(
+ "/chat/completions",
+ "/responses",
+ "/responses/[^/]+",
+ "/responses/[^/]+/input_items",
+ "/responses/[^/]+/cancel");
+
+ @Override
+ @Nonnull
+ public HttpResponse execute(
+ @Nonnull final HttpRequest request, @Nonnull final RequestOptions requestOptions) {
+ validateAllowedEndpoint(request);
+ final var apacheClient = ApacheHttpClient5Accessor.getHttpClient(destination);
+ final var apacheRequest = toApacheRequest(request);
+
+ try {
+ if (isStreaming(request)) {
+ final var apacheResponse = apacheClient.executeOpen(null, apacheRequest, null);
+ final int statusCode = apacheResponse.getCode();
+ if (statusCode >= 200 && statusCode < 300) {
+ return createStreamingResponse(apacheResponse);
+ }
+ return createBufferedResponse(apacheResponse);
+ } else {
+ return apacheClient.execute(apacheRequest, this::createBufferedResponse);
+ }
+ } catch (final IOException e) {
+ throw new OpenAIIoException("HTTP request execution failed", e);
+ }
+ }
+
+ @Override
+ @Nonnull
+ public CompletableFuture executeAsync(
+ @Nonnull final HttpRequest request, @Nonnull final RequestOptions requestOptions) {
+ return CompletableFuture.supplyAsync(
+ () -> execute(request, requestOptions), ThreadContextExecutors.getExecutor());
+ }
+
+ @Override
+ public void close() {
+ // Apache HttpClient lifecycle is managed by Cloud SDK's ApacheHttpClient5Cache
+ }
+
+ private static void validateAllowedEndpoint(@Nonnull final HttpRequest request) {
+ final var endpoint = "/" + String.join("/", request.pathSegments());
+ if (ALLOWED_PATHS.stream().noneMatch(endpoint::matches)) {
+ throw new UnsupportedOperationException(
+ String.format(
+ "Only requests to the following endpoints are allowed: %s.", ALLOWED_PATHS));
+ }
+ }
+
+ @Nonnull
+ private ClassicHttpRequest toApacheRequest(@Nonnull final HttpRequest request) {
+ final var fullUri = buildUrlWithQueryParams(request);
+ final var method = request.method();
+ final var apacheRequest = new BasicClassicHttpRequest(method.name(), fullUri.toString());
+ applyRequestHeaders(request, apacheRequest);
+
+ try (var requestBody = request.body()) {
+ if (requestBody != null) {
+ try (var outputStream = new ByteArrayOutputStream()) {
+ requestBody.writeTo(outputStream);
+ final var bodyBytes = outputStream.toByteArray();
+
+ final var apacheContentType =
+ Optional.ofNullable(requestBody.contentType())
+ .map(ContentType::parse)
+ .orElse(ContentType.APPLICATION_JSON);
+
+ apacheRequest.setEntity(new ByteArrayEntity(bodyBytes, apacheContentType));
+ return apacheRequest;
+ } catch (final IOException e) {
+ throw new OpenAIIoException("Failed to read request body", e);
+ }
+ }
+ }
+
+ return apacheRequest;
+ }
+
+ private static URI buildUrlWithQueryParams(@Nonnull final HttpRequest request) {
+ try {
+ final var uriBuilder = new URIBuilder(request.url());
+ final var queryParams = request.queryParams();
+
+ for (final var key : queryParams.keys()) {
+ for (final var value : queryParams.values(key)) {
+ uriBuilder.addParameter(key, value);
+ }
+ }
+
+ return uriBuilder.build();
+ } catch (URISyntaxException e) {
+ throw new OpenAIIoException("Failed to build URI with query parameters", e);
+ }
+ }
+
+ private static void applyRequestHeaders(
+ @Nonnull final HttpRequest request, @Nonnull final BasicClassicHttpRequest apacheRequest) {
+ final var headers = request.headers();
+ for (final var name : headers.names()) {
+ if ("Authorization".equalsIgnoreCase(name)) {
+ continue;
+ }
+ for (final var value : headers.values(name)) {
+ apacheRequest.addHeader(name, value);
+ }
+ }
+ }
+
+ private static boolean isStreaming(@Nonnull final HttpRequest request) {
+ return request.headers().values("Accept").stream()
+ .map(value -> Objects.toString(value, "").toLowerCase(Locale.ROOT))
+ .anyMatch(value -> value.contains(SSE_MEDIA_TYPE));
+ }
+
+ @Nonnull
+ private static Headers extractResponseHeaders(
+ @Nonnull final ClassicHttpResponse apacheResponse) {
+ final var builder = Headers.builder();
+ for (final var header : apacheResponse.getHeaders()) {
+ builder.put(header.getName(), header.getValue());
+ }
+ return builder.build();
+ }
+
+ @Nonnull
+ private HttpResponse createStreamingResponse(@Nonnull final ClassicHttpResponse apacheResponse)
+ throws IOException {
+
+ final var statusCode = apacheResponse.getCode();
+ final var headers = extractResponseHeaders(apacheResponse);
+
+ final var liveStream =
+ apacheResponse.getEntity() != null
+ ? apacheResponse.getEntity().getContent()
+ : InputStream.nullInputStream();
+
+ return new StreamingHttpResponse(statusCode, headers, liveStream, apacheResponse);
+ }
+
+ @Nonnull
+ private HttpResponse createBufferedResponse(@Nonnull final ClassicHttpResponse apacheResponse)
+ throws IOException {
+ try (apacheResponse) {
+ final int statusCode = apacheResponse.getCode();
+ final Headers headers = extractResponseHeaders(apacheResponse);
+
+ final byte[] body =
+ apacheResponse.getEntity() != null
+ ? EntityUtils.toByteArray(apacheResponse.getEntity())
+ : new byte[0];
+
+ return new BufferedHttpResponse(statusCode, headers, body);
+ }
+ }
+
+ /**
+ * HTTP response for streaming requests. Keeps the connection open and provides a live stream.
+ * The stream must be closed by calling {@link #close()}.
+ */
+ record StreamingHttpResponse(
+ int statusCode,
+ @Nonnull Headers headers,
+ @Nonnull InputStream body,
+ @Nonnull ClassicHttpResponse apacheResponse)
+ implements HttpResponse {
+
+ @Override
+ public void close() {
+ try {
+ body.close();
+ apacheResponse.close();
+ log.debug("Closed streaming response connection");
+ } catch (final IOException e) {
+ log.warn("Failed to close streaming response", e);
+ }
+ }
+ }
+
+ /**
+ * HTTP response for buffered requests. The entire response body is loaded into memory. No
+ * resources need to be closed.
+ */
+ record BufferedHttpResponse(int statusCode, @Nonnull Headers headers, @Nonnull byte[] bodyBytes)
+ implements HttpResponse {
+
+ @Nonnull
+ @Override
+ public InputStream body() {
+ return new ByteArrayInputStream(bodyBytes);
+ }
+
+ @Override
+ public void close() {
+ // Body already consumed and buffered, nothing to close
+ }
+ }
+ }
+}
diff --git a/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/AiCoreOpenAiClientTest.java b/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/AiCoreOpenAiClientTest.java
new file mode 100644
index 000000000..26687df52
--- /dev/null
+++ b/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/AiCoreOpenAiClientTest.java
@@ -0,0 +1,57 @@
+package com.sap.ai.sdk.foundationmodels.openai;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo;
+import com.github.tomakehurst.wiremock.junit5.WireMockTest;
+import com.openai.client.OpenAIClient;
+import com.openai.models.responses.Response;
+import com.openai.models.responses.ResponseCreateParams;
+import com.openai.models.responses.ResponseStatus;
+import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Accessor;
+import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Cache;
+import com.sap.cloud.sdk.cloudplatform.connectivity.DefaultHttpDestination;
+import javax.annotation.Nonnull;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+@WireMockTest
+class AiCoreOpenAiClientTest {
+
+ private OpenAIClient client;
+
+ @BeforeEach
+ void setup(@Nonnull final WireMockRuntimeInfo server) {
+ // Create destination pointing to WireMock server
+ final var destination = DefaultHttpDestination.builder(server.getHttpBaseUrl()).build();
+
+ // Create OpenAI client using our custom implementation
+ client = AiCoreOpenAiClient.fromDestination(destination);
+
+ // Disable HTTP client caching for tests to ensure fresh clients
+ ApacheHttpClient5Accessor.setHttpClientCache(ApacheHttpClient5Cache.DISABLED);
+ }
+
+ @AfterEach
+ void reset() {
+ ApacheHttpClient5Accessor.setHttpClientCache(null);
+ ApacheHttpClient5Accessor.setHttpClientFactory(null);
+ }
+
+ @Test
+ void testResponseSuccess() {
+ final var params =
+ ResponseCreateParams.builder()
+ .input("What is the capital of France?")
+ .model("gpt-5")
+ .build();
+
+ final Response response = client.responses().create(params);
+
+ assertThat(response).isNotNull();
+ assertThat(response.id()).isEqualTo("resp_01a38d2783b385be0069bd43d260108193aef990678aa8a0af");
+ assertThat(response.status().orElseThrow()).isEqualTo(ResponseStatus.COMPLETED);
+ assertThat(response.output()).isNotEmpty();
+ }
+}
diff --git a/foundation-models/openai/src/test/resources/mappings/createResponse.json b/foundation-models/openai/src/test/resources/mappings/createResponse.json
new file mode 100644
index 000000000..bf013a069
--- /dev/null
+++ b/foundation-models/openai/src/test/resources/mappings/createResponse.json
@@ -0,0 +1,158 @@
+{
+ "request": {
+ "method": "POST",
+ "urlPattern": "/responses",
+ "bodyPatterns": [
+ {
+ "equalToJson": {
+ "input": "What is the capital of France?",
+ "model": "gpt-5"
+ }
+ }
+ ]
+ },
+ "response": {
+ "status": 200,
+ "headers": {
+ "Content-Type": "application/json",
+ "x-request-id": "a1c0c7e7-58a0-957c-bda8-1cf971154766",
+ "ai-inference-id": "a1c0c7e7-58a0-957c-bda8-1cf971154766",
+ "x-upstream-service-time": "1474"
+ },
+ "jsonBody": {
+ "background": false,
+ "completed_at": 1774011347,
+ "content_filters": [
+ {
+ "blocked": false,
+ "content_filter_offsets": {
+ "check_offset": 0,
+ "end_offset": 60,
+ "start_offset": 30
+ },
+ "content_filter_raw": [],
+ "content_filter_results": {
+ "hate": {
+ "filtered": false,
+ "severity": "safe"
+ },
+ "self_harm": {
+ "filtered": false,
+ "severity": "safe"
+ },
+ "sexual": {
+ "filtered": false,
+ "severity": "safe"
+ },
+ "violence": {
+ "filtered": false,
+ "severity": "safe"
+ }
+ },
+ "source_type": "prompt"
+ },
+ {
+ "blocked": false,
+ "content_filter_offsets": {
+ "check_offset": 0,
+ "end_offset": 221,
+ "start_offset": 0
+ },
+ "content_filter_raw": [],
+ "content_filter_results": {
+ "hate": {
+ "filtered": false,
+ "severity": "safe"
+ },
+ "protected_material_code": {
+ "detected": false,
+ "filtered": false
+ },
+ "self_harm": {
+ "filtered": false,
+ "severity": "safe"
+ },
+ "sexual": {
+ "filtered": false,
+ "severity": "safe"
+ },
+ "violence": {
+ "filtered": false,
+ "severity": "safe"
+ }
+ },
+ "source_type": "completion"
+ }
+ ],
+ "created_at": 1774011346,
+ "error": null,
+ "frequency_penalty": 0.0,
+ "id": "resp_01a38d2783b385be0069bd43d260108193aef990678aa8a0af",
+ "incomplete_details": null,
+ "instructions": null,
+ "max_output_tokens": null,
+ "max_tool_calls": null,
+ "metadata": {},
+ "model": "gpt-5-2025-08-07-dz-std",
+ "object": "response",
+ "output": [
+ {
+ "id": "rs_01a38d2783b385be0069bd43d2de808193a34727b53738d2e1",
+ "summary": [],
+ "type": "reasoning"
+ },
+ {
+ "content": [
+ {
+ "annotations": [],
+ "logprobs": [],
+ "text": "Paris.",
+ "type": "output_text"
+ }
+ ],
+ "id": "msg_01a38d2783b385be0069bd43d354388193a870074cabde1603",
+ "role": "assistant",
+ "status": "completed",
+ "type": "message"
+ }
+ ],
+ "parallel_tool_calls": true,
+ "presence_penalty": 0.0,
+ "previous_response_id": null,
+ "prompt_cache_key": null,
+ "prompt_cache_retention": null,
+ "reasoning": {
+ "effort": "medium",
+ "summary": null
+ },
+ "safety_identifier": null,
+ "service_tier": "default",
+ "status": "completed",
+ "store": true,
+ "temperature": 1.0,
+ "text": {
+ "format": {
+ "type": "text"
+ },
+ "verbosity": "medium"
+ },
+ "tool_choice": "auto",
+ "tools": [],
+ "top_logprobs": 0,
+ "top_p": 1.0,
+ "truncation": "disabled",
+ "usage": {
+ "input_tokens": 13,
+ "input_tokens_details": {
+ "cached_tokens": 0
+ },
+ "output_tokens": 59,
+ "output_tokens_details": {
+ "reasoning_tokens": 0
+ },
+ "total_tokens": 72
+ },
+ "user": null
+ }
+ }
+}
\ No newline at end of file
diff --git a/pom.xml b/pom.xml
index d5732ec82..36a2ceac1 100644
--- a/pom.xml
+++ b/pom.xml
@@ -73,6 +73,8 @@
4.38.0
2.21.1
1.5.32
+ 1.9.10
+ 4.29.0
1.16.4
20251224
@@ -261,6 +263,32 @@
spring-app
${project.version}
+