Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
27b7e3f
Minimal new module setup including spec
rpanackal Mar 3, 2026
a2c9456
Generation partial-success
rpanackal Mar 3, 2026
aed34ae
Remove examples
rpanackal Mar 6, 2026
9e7c90d
Successfully filter by path
rpanackal Mar 10, 2026
a9a299a
Attach spec filter command
rpanackal Mar 10, 2026
eaf74ba
Initial setup
rpanackal Mar 17, 2026
372769e
Successful PoC with OpenAI Models
rpanackal Mar 18, 2026
3726089
Version 1
rpanackal Mar 20, 2026
01c7f61
Stable api
rpanackal Mar 20, 2026
c921a86
Change class name
rpanackal Mar 20, 2026
2491d07
Add tests
rpanackal Mar 20, 2026
c004481
fix dependency analyse issues
rpanackal Mar 20, 2026
fbff9b0
Initial draft untested
rpanackal Mar 21, 2026
68524b2
Second draft
rpanackal Mar 21, 2026
37ea275
Successful E2E
rpanackal Mar 23, 2026
3bda22a
Streaming initial draft
rpanackal Mar 23, 2026
229db60
Streaming E2E with chat completion
rpanackal Mar 24, 2026
7ffe122
isStreaming check simplified
rpanackal Mar 24, 2026
5553e1b
Cleanup PoC and rename module
rpanackal Mar 24, 2026
2a55080
Reduce Javadoc verbosity
rpanackal Mar 24, 2026
4ca8af1
Restrict to `/responses` api
rpanackal Mar 24, 2026
7061cbc
Cleanup comments
rpanackal Mar 24, 2026
6eb57fc
Charles review suggestions
rpanackal Mar 26, 2026
38ced86
Charles review - round 2 suggestions
rpanackal Mar 26, 2026
d8e1902
Add dependency
rpanackal Mar 26, 2026
dae1906
Mark openai dependency optional and new client `@Beta`
rpanackal Mar 26, 2026
88ae43c
Cleanup and no throw on missing model
rpanackal Mar 30, 2026
c995bee
pmd
rpanackal Mar 30, 2026
145f162
Responses API complete
rpanackal Mar 31, 2026
bf29f13
ChatCompletionCreateParams throws without model. Needs rethink client…
rpanackal Apr 1, 2026
96feb25
Cleanup and close with test documenting limitation
rpanackal Apr 1, 2026
2bf90df
Merge branch 'feat/poc-openai-responses-apache' into feat/poc-aicore-…
rpanackal Apr 2, 2026
8af4640
First draft responses only
rpanackal Apr 8, 2026
e2f8fd1
jacoco limits
rpanackal Apr 8, 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
8 changes: 4 additions & 4 deletions foundation-models/openai/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,11 @@
</scm>
<properties>
<project.rootdir>${project.basedir}/../../</project.rootdir>
<coverage.complexity>77%</coverage.complexity>
<coverage.line>87%</coverage.line>
<coverage.instruction>85%</coverage.instruction>
<coverage.complexity>75%</coverage.complexity>
<coverage.line>86%</coverage.line>
<coverage.instruction>84%</coverage.instruction>
<coverage.branch>75%</coverage.branch>
<coverage.method>87%</coverage.method>
<coverage.method>82%</coverage.method>
<coverage.class>91%</coverage.class>
</properties>
<dependencies>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
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.openai.services.blocking.ResponseService;
import com.openai.services.blocking.ResponseServiceImpl;
import com.sap.ai.sdk.core.AiCoreService;
import com.sap.ai.sdk.core.DeploymentResolutionException;
import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Accessor;
Expand Down Expand Up @@ -48,7 +48,7 @@
@Slf4j
@NoArgsConstructor(access = AccessLevel.PRIVATE)
@Beta
public final class AiCoreOpenAiClient {
public class AiCoreOpenAiClient {

private static final String DEFAULT_RESOURCE_GROUP = "default";

Expand All @@ -61,8 +61,8 @@ public final class AiCoreOpenAiClient {
* @throws DeploymentResolutionException If no running deployment is found for the model.
*/
@Nonnull
public static OpenAIClient forModel(@Nonnull final OpenAiModel model) {
return forModel(model, DEFAULT_RESOURCE_GROUP);
public static ResponseService responses(@Nonnull final OpenAiModel model) {
return responses(model, DEFAULT_RESOURCE_GROUP);
}

/**
Expand All @@ -75,30 +75,27 @@ public static OpenAIClient forModel(@Nonnull final OpenAiModel model) {
* @throws DeploymentResolutionException If no running deployment is found for the model.
*/
@Nonnull
public static OpenAIClient forModel(
public static ResponseService responses(
@Nonnull final OpenAiModel model, @Nonnull final String resourceGroup) {
final HttpDestination destination =
new AiCoreService().getInferenceDestination(resourceGroup).forModel(model);

return fromDestination(destination);
return responses(model, 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);
static ResponseService responses(
@Nonnull final OpenAiModel model, @Nonnull final HttpDestination destination) {
final var clientOptions =
ClientOptions.builder()
.baseUrl(destination.getUri().toString())
.httpClient(new AiCoreHttpClientImpl(destination))
.apiKey("unused")
.build();
final var chatModel =
Optional.ofNullable(model.version())
.map(version -> model.name() + "-" + version)
.orElseGet(model::name);
return new AiCoreResponseService(new ResponseServiceImpl(clientOptions), chatModel);
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
package com.sap.ai.sdk.foundationmodels.openai;

import com.openai.core.ClientOptions;
import com.openai.core.RequestOptions;
import com.openai.core.http.StreamResponse;
import com.openai.models.ChatModel;
import com.openai.models.ResponsesModel;
import com.openai.models.responses.CompactedResponse;
import com.openai.models.responses.Response;
import com.openai.models.responses.ResponseCancelParams;
import com.openai.models.responses.ResponseCompactParams;
import com.openai.models.responses.ResponseCreateParams;
import com.openai.models.responses.ResponseDeleteParams;
import com.openai.models.responses.ResponseRetrieveParams;
import com.openai.models.responses.ResponseStreamEvent;
import com.openai.models.responses.StructuredResponse;
import com.openai.models.responses.StructuredResponseCreateParams;
import com.openai.services.blocking.ResponseService;
import com.openai.services.blocking.responses.InputItemService;
import com.openai.services.blocking.responses.InputTokenService;
import java.util.function.Consumer;
import javax.annotation.Nonnull;
import lombok.AccessLevel;
import lombok.RequiredArgsConstructor;
import lombok.experimental.Delegate;

@RequiredArgsConstructor(access = AccessLevel.PACKAGE)
class AiCoreResponseService implements ResponseService {

@Delegate(types = PassThroughMethods.class)
private final ResponseService delegate;

private final String deploymentModel;

@Override
@Nonnull
public ResponseService withOptions(@Nonnull final Consumer<ClientOptions.Builder> modifier) {
return new AiCoreResponseService(delegate.withOptions(modifier), deploymentModel);
}

@Override
@Nonnull
public ResponseService.WithRawResponse withRawResponse() {
throw new UnsupportedOperationException(
"withRawResponse() is not supported by AiCoreResponseService.");
}

@Override
@Nonnull
public Response create(@Nonnull final ResponseCreateParams params) {
return create(params, RequestOptions.none());
}

@Override
@Nonnull
public Response create(
@Nonnull final ResponseCreateParams params, @Nonnull final RequestOptions requestOptions) {
return delegate.create(useDeploymentModel(params), requestOptions);
}

@Override
@Nonnull
public <T> StructuredResponse<T> create(@Nonnull final StructuredResponseCreateParams<T> params) {
return create(params, RequestOptions.none());
}

@Override
@Nonnull
public <T> StructuredResponse<T> create(
@Nonnull final StructuredResponseCreateParams<T> params,
@Nonnull final RequestOptions requestOptions) {
return delegate.create(useDeploymentModel(params), requestOptions);
}

@Override
@Nonnull
public StreamResponse<ResponseStreamEvent> createStreaming(
@Nonnull final ResponseCreateParams params) {
return createStreaming(params, RequestOptions.none());
}

@Override
@Nonnull
public StreamResponse<ResponseStreamEvent> createStreaming(
@Nonnull final ResponseCreateParams params, @Nonnull final RequestOptions requestOptions) {
return delegate.createStreaming(useDeploymentModel(params), requestOptions);
}

@Override
@Nonnull
public StreamResponse<ResponseStreamEvent> createStreaming(
@Nonnull final StructuredResponseCreateParams<?> params) {
return createStreaming(params, RequestOptions.none());
}

@Override
@Nonnull
public StreamResponse<ResponseStreamEvent> createStreaming(
@Nonnull final StructuredResponseCreateParams<?> params,
@Nonnull final RequestOptions requestOptions) {
return delegate.createStreaming(useDeploymentModel(params), requestOptions);
}

@Override
@Nonnull
public CompactedResponse compact(@Nonnull final ResponseCompactParams params) {
return compact(params, RequestOptions.none());
}

@Override
@Nonnull
public CompactedResponse compact(
@Nonnull final ResponseCompactParams params, @Nonnull final RequestOptions requestOptions) {
return delegate.compact(useDeploymentModel(params), requestOptions);
}

@Nonnull
private <T> StructuredResponseCreateParams<T> useDeploymentModel(
@Nonnull final StructuredResponseCreateParams<T> params) {
final ResponseCreateParams validated = useDeploymentModel(params.rawParams());
return new StructuredResponseCreateParams<>(params.responseType(), validated);
}

@Nonnull
private ResponseCreateParams useDeploymentModel(@Nonnull final ResponseCreateParams params) {
final var givenModel =
params.model().map(ResponsesModel::asChat).map(ChatModel::asString).orElse(null);

if (givenModel == null) {
return params.toBuilder().model(deploymentModel).build();
}

throwOnModelMismatch(givenModel);
return params;
}

@Nonnull
private ResponseCompactParams useDeploymentModel(@Nonnull final ResponseCompactParams params) {
final String givenModel =
params.model().map(ResponseCompactParams.Model::asString).orElse(null);

if (givenModel == null) {
return params.toBuilder().model(deploymentModel).build();
}

throwOnModelMismatch(givenModel);
return params;
}

private void throwOnModelMismatch(@Nonnull final String givenModel) {
if (!deploymentModel.equals(givenModel)) {
throw new IllegalArgumentException(
"""
Model mismatch:
Expected : '%s' (configured via forModel())
Actual : '%s' (set in request parameters)
Fix: Either remove the model from the request parameters, \
or use forModel("%s") when creating the client.\
"""
.formatted(deploymentModel, givenModel, givenModel));
}
}

private interface PassThroughMethods {
InputItemService inputItems();

InputTokenService inputTokens();

Response retrieve(ResponseRetrieveParams params, RequestOptions requestOptions);

Response retrieve(ResponseRetrieveParams params);

StreamResponse<ResponseStreamEvent> retrieveStreaming(
ResponseRetrieveParams params, RequestOptions requestOptions);

StreamResponse<ResponseStreamEvent> retrieveStreaming(ResponseRetrieveParams params);

void delete(ResponseDeleteParams params, RequestOptions requestOptions);

void delete(ResponseDeleteParams params);

Response cancel(ResponseCancelParams params, RequestOptions requestOptions);

Response cancel(ResponseCancelParams params);
}
}
Original file line number Diff line number Diff line change
@@ -1,20 +1,19 @@
package com.sap.ai.sdk.foundationmodels.openai;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo;
import com.github.tomakehurst.wiremock.junit5.WireMockTest;
import com.openai.client.OpenAIClient;
import com.openai.core.http.QueryParams;
import com.openai.models.ChatModel;
import com.openai.models.chat.completions.ChatCompletion;
import com.openai.models.chat.completions.ChatCompletionCreateParams;
import com.openai.models.responses.Response;
import com.openai.models.responses.ResponseCreateParams;
import com.openai.models.responses.ResponseStatus;
import com.openai.services.blocking.ResponseService;
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 com.sap.cloud.sdk.cloudplatform.connectivity.HttpDestination;
import javax.annotation.Nonnull;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
Expand All @@ -23,13 +22,13 @@
@WireMockTest
class AiCoreOpenAiClientTest {

private OpenAIClient client;
private ResponseService responseClient;

@BeforeEach
void setup(@Nonnull final WireMockRuntimeInfo server) {
final var destination = DefaultHttpDestination.builder(server.getHttpBaseUrl()).build();
client = AiCoreOpenAiClient.fromDestination(destination);

final HttpDestination destination =
DefaultHttpDestination.builder(server.getHttpBaseUrl()).build();
responseClient = AiCoreOpenAiClient.responses(OpenAiModel.GPT_5, destination);
ApacheHttpClient5Accessor.setHttpClientCache(ApacheHttpClient5Cache.DISABLED);
}

Expand All @@ -47,22 +46,46 @@ void testResponseServiceSuccessWithMatchingModel() {
.model(ChatModel.GPT_5)
.build();

final Response response = client.responses().create(params);
final Response response = responseClient.create(params);

assertThat(response).isNotNull();
assertThat(response.status().orElseThrow()).isEqualTo(ResponseStatus.COMPLETED);
}

@Test
void testChatCompletionServiceSuccessWithMatchingModel() {
void testResponseServiceSuccessWithoutModel() {
final var params =
ChatCompletionCreateParams.builder()
.model(ChatModel.GPT_5)
.addUserMessage("Say this is a test")
.additionalQueryParams(QueryParams.builder().put("api-version", "2024-02-01").build())
.build();
ResponseCreateParams.builder().input("What is the capital of France?").build();
final Response response = responseClient.create(params);

final ChatCompletion response = client.chat().completions().create(params);
assertThat(response).isNotNull();
assertThat(response.status().orElseThrow()).isEqualTo(ResponseStatus.COMPLETED);
}

@Test
void testResponseServiceFailsWithModelMismatch() {
final var params =
ResponseCreateParams.builder()
.input("What is the capital of France?")
.model(ChatModel.GPT_4) // Different from client's expected model "gpt-5"
.build();

assertThatThrownBy(() -> responseClient.create(params))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Model mismatch")
.hasMessageContaining("gpt-5")
.hasMessageContaining("gpt-4");
}

@Test
void testResponseServiceWithOptions() {
final var modifiedService =
responseClient.withOptions(
builder -> {
// Modify some option
builder.putHeader("X-Custom-Header", "test-value");
});

assertThat(modifiedService).isInstanceOf(AiCoreResponseService.class);
}
}
Loading
Loading