diff --git a/foundation-models/openai/pom.xml b/foundation-models/openai/pom.xml index f08849fb2..d3b240bdf 100644 --- a/foundation-models/openai/pom.xml +++ b/foundation-models/openai/pom.xml @@ -38,11 +38,11 @@ ${project.basedir}/../../ - 77% - 87% - 85% + 75% + 86% + 84% 75% - 87% + 82% 91% 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 index ce4bdf487..efd62914c 100644 --- 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 @@ -1,8 +1,6 @@ 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; @@ -10,6 +8,8 @@ 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; @@ -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"; @@ -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); } /** @@ -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); } /** diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/AiCoreResponseService.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/AiCoreResponseService.java new file mode 100644 index 000000000..090aa639a --- /dev/null +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/AiCoreResponseService.java @@ -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 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 StructuredResponse create(@Nonnull final StructuredResponseCreateParams params) { + return create(params, RequestOptions.none()); + } + + @Override + @Nonnull + public StructuredResponse create( + @Nonnull final StructuredResponseCreateParams params, + @Nonnull final RequestOptions requestOptions) { + return delegate.create(useDeploymentModel(params), requestOptions); + } + + @Override + @Nonnull + public StreamResponse createStreaming( + @Nonnull final ResponseCreateParams params) { + return createStreaming(params, RequestOptions.none()); + } + + @Override + @Nonnull + public StreamResponse createStreaming( + @Nonnull final ResponseCreateParams params, @Nonnull final RequestOptions requestOptions) { + return delegate.createStreaming(useDeploymentModel(params), requestOptions); + } + + @Override + @Nonnull + public StreamResponse createStreaming( + @Nonnull final StructuredResponseCreateParams params) { + return createStreaming(params, RequestOptions.none()); + } + + @Override + @Nonnull + public StreamResponse 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 StructuredResponseCreateParams useDeploymentModel( + @Nonnull final StructuredResponseCreateParams 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 retrieveStreaming( + ResponseRetrieveParams params, RequestOptions requestOptions); + + StreamResponse retrieveStreaming(ResponseRetrieveParams params); + + void delete(ResponseDeleteParams params, RequestOptions requestOptions); + + void delete(ResponseDeleteParams params); + + Response cancel(ResponseCancelParams params, RequestOptions requestOptions); + + Response cancel(ResponseCancelParams params); + } +} 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 index d2741ee34..b0a68342b 100644 --- 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 @@ -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; @@ -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); } @@ -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); } } diff --git a/foundation-models/openai/src/test/resources/mappings/chatCompletion.json b/foundation-models/openai/src/test/resources/mappings/chatCompletion.json deleted file mode 100644 index bb5358a84..000000000 --- a/foundation-models/openai/src/test/resources/mappings/chatCompletion.json +++ /dev/null @@ -1,104 +0,0 @@ -{ - "request": { - "method": "POST", - "urlPattern": "/chat/completions\\?api-version=2024-02-01", - "bodyPatterns": [ - { - "equalToJson": { - "messages": [ - { - "content": "Say this is a test", - "role": "user" - } - ], - "model": "gpt-5" - } - } - ] - }, - "response": { - "status": 200, - "headers": { - "Content-Type": "application/json", - "x-request-id": "f181d24e-f41e-9396-a195-6d1334bfe952", - "ai-inference-id": "f181d24e-f41e-9396-a195-6d1334bfe952", - "x-upstream-service-time": "3177" - }, - "jsonBody": { - "choices": [ - { - "content_filter_results": { - "hate": { - "filtered": false, - "severity": "safe" - }, - "self_harm": { - "filtered": false, - "severity": "safe" - }, - "sexual": { - "filtered": false, - "severity": "safe" - }, - "violence": { - "filtered": false, - "severity": "safe" - } - }, - "finish_reason": "stop", - "index": 0, - "logprobs": null, - "message": { - "annotations": [], - "content": "This is a test.", - "refusal": null, - "role": "assistant" - } - } - ], - "created": 1775053782, - "id": "chatcmpl-DPqreavBOHfKfV0orguq4jK5Gbmmh", - "model": "gpt-5-2025-08-07", - "object": "chat.completion", - "prompt_filter_results": [ - { - "content_filter_results": { - "hate": { - "filtered": false, - "severity": "safe" - }, - "self_harm": { - "filtered": false, - "severity": "safe" - }, - "sexual": { - "filtered": false, - "severity": "safe" - }, - "violence": { - "filtered": false, - "severity": "safe" - } - }, - "prompt_index": 0 - } - ], - "system_fingerprint": null, - "usage": { - "completion_tokens": 271, - "completion_tokens_details": { - "accepted_prediction_tokens": 0, - "audio_tokens": 0, - "reasoning_tokens": 256, - "rejected_prediction_tokens": 0 - }, - "prompt_tokens": 11, - "prompt_tokens_details": { - "audio_tokens": 0, - "cached_tokens": 0 - }, - "total_tokens": 282 - } - } - } -} \ No newline at end of file diff --git a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/AiCoreOpenAiService.java b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/AiCoreOpenAiService.java index 243055d67..0c538e7b3 100644 --- a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/AiCoreOpenAiService.java +++ b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/AiCoreOpenAiService.java @@ -2,16 +2,12 @@ import static com.sap.ai.sdk.foundationmodels.openai.OpenAiModel.GPT_5; -import com.openai.client.OpenAIClient; -import com.openai.core.http.QueryParams; import com.openai.core.http.StreamResponse; import com.openai.models.ChatModel; -import com.openai.models.chat.completions.ChatCompletion; -import com.openai.models.chat.completions.ChatCompletionChunk; -import com.openai.models.chat.completions.ChatCompletionCreateParams; import com.openai.models.responses.Response; import com.openai.models.responses.ResponseCreateParams; import com.openai.models.responses.ResponseStreamEvent; +import com.openai.services.blocking.ResponseService; import com.sap.ai.sdk.foundationmodels.openai.AiCoreOpenAiClient; import javax.annotation.Nonnull; import lombok.extern.slf4j.Slf4j; @@ -23,7 +19,8 @@ @Slf4j public class AiCoreOpenAiService { - private static final OpenAIClient CLIENT = AiCoreOpenAiClient.forModel(GPT_5, "ai-sdk-java-e2e"); + private static final ResponseService RESPONSE_CLIENT = + AiCoreOpenAiClient.responses(GPT_5, "ai-sdk-java-e2e"); /** * Create a simple response using the Responses API @@ -35,7 +32,7 @@ public class AiCoreOpenAiService { public Response createResponse(@Nonnull final String input) { val params = ResponseCreateParams.builder().input(input).model(ChatModel.GPT_5).store(false).build(); - return CLIENT.responses().create(params); + return RESPONSE_CLIENT.create(params); } /** @@ -49,8 +46,8 @@ public Response createResponse(@Nonnull final String input) { public Response retrieveResponse(@Nonnull final String input) { // Create a non-persistent response with store=false val params = ResponseCreateParams.builder().input(input).model(ChatModel.GPT_5).build(); - val createResponse = CLIENT.responses().create(params); - return CLIENT.responses().retrieve(createResponse.id()); + val createResponse = RESPONSE_CLIENT.create(params); + return RESPONSE_CLIENT.retrieve(createResponse.id()); } /** @@ -64,42 +61,6 @@ public StreamResponse createStreamingResponse(@Nonnull fina // Create a non-persistent response with store=false val params = ResponseCreateParams.builder().input(input).model(ChatModel.GPT_5).store(false).build(); - return CLIENT.responses().createStreaming(params); - } - - /** - * Create a chat completion using the Chat Completions API. Note: This uses the legacy API version - * format via query parameters. - * - * @param input the input text to send to the model - * @return the chat completion response from the Chat Completions API - */ - @Nonnull - public ChatCompletion createChatCompletion(@Nonnull final String input) { - val params = - ChatCompletionCreateParams.builder() - .addUserMessage(input) - .model(ChatModel.GPT_5) - .additionalQueryParams(QueryParams.builder().put("api-version", "2024-02-01").build()) - .build(); - return CLIENT.chat().completions().create(params); - } - - /** - * Create a streaming chat completion using the Chat Completions API - * - * @param input the input text to send to the model - * @return the streaming chat completion response from the Chat Completions API - */ - @Nonnull - public StreamResponse createStreamingChatCompletion( - @Nonnull final String input) { - val params = - ChatCompletionCreateParams.builder() - .addUserMessage(input) - .model(ChatModel.GPT_5) - .additionalQueryParams(QueryParams.builder().put("api-version", "2024-02-01").build()) - .build(); - return CLIENT.chat().completions().createStreaming(params); + return RESPONSE_CLIENT.createStreaming(params); } } diff --git a/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/AiCoreOpenAiTest.java b/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/AiCoreOpenAiTest.java index 99548e00f..d541f4bd7 100644 --- a/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/AiCoreOpenAiTest.java +++ b/sample-code/spring-app/src/test/java/com/sap/ai/sdk/app/controllers/AiCoreOpenAiTest.java @@ -51,28 +51,4 @@ void testCreateStreamingResponse() { assertThat(hasTextDeltas).isTrue(); } } - - @Test - void testCreateChatCompletion() { - final var response = service.createChatCompletion("What is the capital of France?"); - assertThat(response).isNotNull(); - } - - @Test - void testCreateStreamingChatCompletion() { - try (final var streamResponse = - service.createStreamingChatCompletion("What is the capital of France?")) { - final var events = streamResponse.stream().collect(Collectors.toList()); - - assertThat(events).isNotEmpty(); - - final var hasContentDeltas = - events.stream() - .anyMatch( - event -> - !event.choices().isEmpty() - && event.choices().get(0).delta().content().isPresent()); - assertThat(hasContentDeltas).isTrue(); - } - } }