diff --git a/foundation-models/openai/pom.xml b/foundation-models/openai/pom.xml index 9bc1bde85..eecd1b41c 100644 --- a/foundation-models/openai/pom.xml +++ b/foundation-models/openai/pom.xml @@ -38,11 +38,11 @@ ${project.basedir}/../../ - 77% - 87% - 85% - 75% - 87% + 73% + 85% + 82% + 74% + 79% 91% diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/AiCoreChatCompletionService.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/AiCoreChatCompletionService.java new file mode 100644 index 000000000..afd307be1 --- /dev/null +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/AiCoreChatCompletionService.java @@ -0,0 +1,174 @@ +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.chat.completions.ChatCompletion; +import com.openai.models.chat.completions.ChatCompletionChunk; +import com.openai.models.chat.completions.ChatCompletionCreateParams; +import com.openai.models.chat.completions.ChatCompletionDeleteParams; +import com.openai.models.chat.completions.ChatCompletionDeleted; +import com.openai.models.chat.completions.ChatCompletionListPage; +import com.openai.models.chat.completions.ChatCompletionListParams; +import com.openai.models.chat.completions.ChatCompletionRetrieveParams; +import com.openai.models.chat.completions.ChatCompletionUpdateParams; +import com.openai.models.chat.completions.StructuredChatCompletion; +import com.openai.models.chat.completions.StructuredChatCompletionCreateParams; +import com.openai.services.blocking.chat.ChatCompletionService; +import com.openai.services.blocking.chat.completions.MessageService; +import java.util.function.Consumer; +import javax.annotation.Nonnull; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import lombok.experimental.Delegate; + +@RequiredArgsConstructor(access = AccessLevel.PACKAGE) +class AiCoreChatCompletionService implements ChatCompletionService { + + @Delegate(types = PassThroughMethods.class) + private final ChatCompletionService delegate; + + private final String deploymentModel; + + @Override + @Nonnull + public ChatCompletionService withOptions( + @Nonnull final Consumer consumer) { + return new AiCoreChatCompletionService(delegate.withOptions(consumer), deploymentModel); + } + + @Override + @Nonnull + public ChatCompletionService.WithRawResponse withRawResponse() { + throw new UnsupportedOperationException( + "withRawResponse() is not supported by AiCoreResponseService."); + } + + @Override + @Nonnull + public ChatCompletion create(@Nonnull final ChatCompletionCreateParams params) { + return create(params, RequestOptions.none()); + } + + @Override + @Nonnull + public ChatCompletion create( + @Nonnull final ChatCompletionCreateParams params, + @Nonnull final RequestOptions requestOptions) { + throwOnModelMismatch(params.model().asString()); + return delegate.create(params, requestOptions); + } + + @Override + @Nonnull + public StructuredChatCompletion create( + @Nonnull final StructuredChatCompletionCreateParams params) { + return create(params, RequestOptions.none()); + } + + @Override + @Nonnull + public StructuredChatCompletion create( + @Nonnull final StructuredChatCompletionCreateParams params, + @Nonnull final RequestOptions requestOptions) { + throwOnModelMismatch(params.rawParams().model().asString()); + return delegate.create(params, requestOptions); + } + + @Override + @Nonnull + public StreamResponse createStreaming( + @Nonnull final ChatCompletionCreateParams params) { + return createStreaming(params, RequestOptions.none()); + } + + @Override + @Nonnull + public StreamResponse createStreaming( + @Nonnull final ChatCompletionCreateParams params, + @Nonnull final RequestOptions requestOptions) { + throwOnModelMismatch(params.model().asString()); + return delegate.createStreaming(params, requestOptions); + } + + @Override + @Nonnull + public StreamResponse createStreaming( + @Nonnull final StructuredChatCompletionCreateParams params) { + return createStreaming(params, RequestOptions.none()); + } + + @Override + @Nonnull + public StreamResponse createStreaming( + @Nonnull final StructuredChatCompletionCreateParams params, + @Nonnull final RequestOptions requestOptions) { + throwOnModelMismatch(params.rawParams().model().asString()); + return delegate.createStreaming(params, requestOptions); + } + + 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 { + ChatCompletionDeleted delete( + ChatCompletionDeleteParams chatCompletionDeleteParams, RequestOptions requestOptions); + + ChatCompletionDeleted delete(String completionId); + + ChatCompletionDeleted delete(String completionId, ChatCompletionDeleteParams params); + + ChatCompletionDeleted delete( + String completionId, ChatCompletionDeleteParams params, RequestOptions requestOptions); + + ChatCompletionDeleted delete(String completionId, RequestOptions requestOptions); + + ChatCompletionDeleted delete(ChatCompletionDeleteParams params); + + ChatCompletionListPage list(); + + ChatCompletionListPage list( + ChatCompletionListParams chatCompletionListParams, RequestOptions requestOptions); + + ChatCompletionListPage list(ChatCompletionListParams params); + + ChatCompletionListPage list(RequestOptions requestOptions); + + MessageService messages(); + + ChatCompletion retrieve( + ChatCompletionRetrieveParams chatCompletionRetrieveParams, RequestOptions requestOptions); + + ChatCompletion retrieve(String completionId); + + ChatCompletion retrieve(String completionId, ChatCompletionRetrieveParams params); + + ChatCompletion retrieve( + String completionId, ChatCompletionRetrieveParams params, RequestOptions requestOptions); + + ChatCompletion retrieve(String completionId, RequestOptions requestOptions); + + ChatCompletion retrieve(ChatCompletionRetrieveParams params); + + ChatCompletion update( + ChatCompletionUpdateParams chatCompletionUpdateParams, RequestOptions requestOptions); + + ChatCompletion update(String completionId, ChatCompletionUpdateParams params); + + ChatCompletion update( + String completionId, ChatCompletionUpdateParams params, RequestOptions requestOptions); + + ChatCompletion update(ChatCompletionUpdateParams params); + } +} diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/AiCoreChatService.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/AiCoreChatService.java new file mode 100644 index 000000000..641255c47 --- /dev/null +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/AiCoreChatService.java @@ -0,0 +1,40 @@ +package com.sap.ai.sdk.foundationmodels.openai; + +import com.openai.core.ClientOptions; +import com.openai.core.http.QueryParams; +import com.openai.services.blocking.ChatService; +import com.openai.services.blocking.chat.ChatCompletionService; +import java.util.function.Consumer; +import javax.annotation.Nonnull; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import lombok.experimental.Delegate; + +@RequiredArgsConstructor(access = AccessLevel.PACKAGE) +class AiCoreChatService implements ChatService { + + @Delegate private final ChatService delegate; + private final String deploymentModel; + + @Override + @Nonnull + public ChatService withOptions(@Nonnull final Consumer consumer) { + return new AiCoreChatService(delegate.withOptions(consumer), deploymentModel); + } + + @Override + @Nonnull + public WithRawResponse withRawResponse() { + throw new UnsupportedOperationException( + "withRawResponse() is not supported for AiCoreChatService"); + } + + @Override + @Nonnull + public ChatCompletionService completions() { + final var apiVersionQuery = QueryParams.builder().put("api-version", "2024-02-01").build(); + final var completions = + delegate.completions().withOptions(builder -> builder.queryParams(apiVersionQuery)); + return new AiCoreChatCompletionService(completions, deploymentModel); + } +} 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 9bf5ff4d3..5d1d97459 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 @@ -10,8 +10,8 @@ import com.openai.core.http.HttpRequest; import com.openai.core.http.HttpResponse; import com.openai.errors.OpenAIIoException; +import com.openai.models.ChatModel; 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; @@ -20,9 +20,8 @@ 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.Map; import java.util.Objects; import java.util.Optional; import java.util.Set; @@ -38,7 +37,6 @@ 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. @@ -64,7 +62,7 @@ public final class AiCoreOpenAiClient { * @throws DeploymentResolutionException If no running deployment is found for the model. */ @Nonnull - public static OpenAIClient forModel(@Nonnull final AiModel model) { + public static OpenAIClient forModel(@Nonnull final OpenAiModel model) { return forModel(model, DEFAULT_RESOURCE_GROUP); } @@ -79,29 +77,34 @@ public static OpenAIClient forModel(@Nonnull final AiModel model) { */ @Nonnull public static OpenAIClient forModel( - @Nonnull final AiModel model, @Nonnull final String resourceGroup) { + @Nonnull final OpenAiModel model, @Nonnull final String resourceGroup) { final HttpDestination destination = new AiCoreService().getInferenceDestination(resourceGroup).forModel(model); - - return fromDestination(destination); + return fromDestination(destination, model); } /** * Create an OpenAI client from a pre-resolved destination. * * @param destination The destination to use. + * @param model The model name for validation. * @return A configured OpenAI client instance. */ @Nonnull @SuppressWarnings("PMD.CloseResource") - static OpenAIClient fromDestination(@Nonnull final HttpDestination destination) { + static OpenAIClient fromDestination( + @Nonnull final HttpDestination destination, @Nonnull final OpenAiModel model) { final var baseUrl = destination.getUri().toString(); final var httpClient = new AiCoreHttpClientImpl(destination); - final ClientOptions clientOptions = + final var clientOptions = ClientOptions.builder().baseUrl(baseUrl).httpClient(httpClient).apiKey("unused").build(); - - return new OpenAIClientImpl(clientOptions); + final var chatModel = + Optional.ofNullable(model.version()) + .map(version -> model.name() + "-" + version) + .orElseGet(model::name); + return new OpenAIClientImplWrapper( + new OpenAIClientImpl(clientOptions), ChatModel.of(chatModel).asString()); } /** @@ -114,19 +117,19 @@ 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"); + private static final Map> ALLOWED_OPERATIONS = + Map.of( + "/chat/completions", Set.of("POST"), + "/responses", Set.of("GET", "POST"), + "/responses/[^/]+", Set.of("GET", "DELETE"), + "/responses/[^/]+/compact", Set.of("POST"), + "/responses/[^/]+/cancel", Set.of("POST")); @Override @Nonnull public HttpResponse execute( @Nonnull final HttpRequest request, @Nonnull final RequestOptions requestOptions) { - validateAllowedEndpoint(request); + validateAllowedOperation(request); final var apacheClient = ApacheHttpClient5Accessor.getHttpClient(destination); final var apacheRequest = toApacheRequest(request); @@ -159,20 +162,33 @@ public void close() { // Apache HttpClient lifecycle is managed by Cloud SDK's ApacheHttpClient5Cache } - private static void validateAllowedEndpoint(@Nonnull final HttpRequest request) { + private static void validateAllowedOperation(@Nonnull final HttpRequest request) { final var endpoint = "/" + String.join("/", request.pathSegments()); - if (ALLOWED_PATHS.stream().noneMatch(endpoint::matches)) { + final var method = request.method().name(); + + // Find matching path pattern + final var matchingEntry = + ALLOWED_OPERATIONS.entrySet().stream() + .filter(entry -> endpoint.matches(entry.getKey())) + .findFirst() + .orElseThrow( + () -> + new UnsupportedOperationException( + String.format("Endpoint %s is not supported in AI Core", endpoint))); + + // Validate method + if (!matchingEntry.getValue().contains(method)) { throw new UnsupportedOperationException( String.format( - "Only requests to the following endpoints are allowed: %s.", ALLOWED_PATHS)); + "HTTP %s method is not supported on endpoint %s in AI Core", method, endpoint)); } } @Nonnull private ClassicHttpRequest toApacheRequest(@Nonnull final HttpRequest request) { - final var fullUri = buildUrlWithQueryParams(request); + final var fullUri = request.url(); final var method = request.method(); - final var apacheRequest = new BasicClassicHttpRequest(method.name(), fullUri.toString()); + final var apacheRequest = new BasicClassicHttpRequest(method.name(), fullUri); applyRequestHeaders(request, apacheRequest); try (var requestBody = request.body()) { @@ -197,23 +213,6 @@ private ClassicHttpRequest toApacheRequest(@Nonnull final HttpRequest request) { 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(); 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/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAIClientImplWrapper.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAIClientImplWrapper.java new file mode 100644 index 000000000..9308f19f6 --- /dev/null +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAIClientImplWrapper.java @@ -0,0 +1,120 @@ +package com.sap.ai.sdk.foundationmodels.openai; + +import com.openai.client.OpenAIClient; +import com.openai.client.OpenAIClientAsync; +import com.openai.client.OpenAIClientImpl; +import com.openai.core.ClientOptions; +import com.openai.services.blocking.AudioService; +import com.openai.services.blocking.BatchService; +import com.openai.services.blocking.BetaService; +import com.openai.services.blocking.ChatService; +import com.openai.services.blocking.CompletionService; +import com.openai.services.blocking.ContainerService; +import com.openai.services.blocking.ConversationService; +import com.openai.services.blocking.EmbeddingService; +import com.openai.services.blocking.EvalService; +import com.openai.services.blocking.FileService; +import com.openai.services.blocking.FineTuningService; +import com.openai.services.blocking.GraderService; +import com.openai.services.blocking.ImageService; +import com.openai.services.blocking.ModelService; +import com.openai.services.blocking.ModerationService; +import com.openai.services.blocking.RealtimeService; +import com.openai.services.blocking.ResponseService; +import com.openai.services.blocking.SkillService; +import com.openai.services.blocking.UploadService; +import com.openai.services.blocking.VectorStoreService; +import com.openai.services.blocking.VideoService; +import com.openai.services.blocking.WebhookService; +import com.sap.ai.sdk.foundationmodels.openai.AiCoreOpenAiClient.AiCoreHttpClientImpl; +import java.util.function.Consumer; +import javax.annotation.Nonnull; +import lombok.RequiredArgsConstructor; +import lombok.experimental.Delegate; + +@RequiredArgsConstructor +class OpenAIClientImplWrapper implements OpenAIClient { + + @Delegate(types = OtherMethods.class) + @Nonnull + private final OpenAIClientImpl delegate; + + @Nonnull private final String deploymentModel; + + @Nonnull + @Override + public OpenAIClient withOptions(@Nonnull final Consumer consumer) { + return new OpenAIClientImplWrapper( + (OpenAIClientImpl) delegate.withOptions(consumer), deploymentModel); + } + + @Nonnull + @Override + public ChatService chat() { + return new AiCoreChatService(delegate.chat(), deploymentModel); + } + + @Override + @Nonnull + public ResponseService responses() { + return new AiCoreResponseService(delegate.responses(), deploymentModel); + } + + /** + * Methods that are delegated to the underlying OpenAI client. + * + *

Note: Most of these methods will throw {@link UnsupportedOperationException} at runtime due + * to endpoint constraints enforced in {@code AiCoreHttpClientImpl}. + * + * @see AiCoreHttpClientImpl#validateAllowedEndpoint + */ + private interface OtherMethods { + OpenAIClientAsync async(); + + OpenAIClient.WithRawResponse withRawResponse(); + + OpenAIClient withOptions(Consumer modifier); + + CompletionService completions(); + + EmbeddingService embeddings(); + + FileService files(); + + ImageService images(); + + AudioService audio(); + + ModerationService moderations(); + + ModelService models(); + + FineTuningService fineTuning(); + + GraderService graders(); + + VectorStoreService vectorStores(); + + WebhookService webhooks(); + + BetaService beta(); + + BatchService batches(); + + UploadService uploads(); + + RealtimeService realtime(); + + ConversationService conversations(); + + EvalService evals(); + + ContainerService containers(); + + SkillService skills(); + + VideoService videos(); + + void 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 index 26687df52..9a3e8667e 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,19 +1,25 @@ 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.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.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Accessor; import com.sap.cloud.sdk.cloudplatform.connectivity.ApacheHttpClient5Cache; import com.sap.cloud.sdk.cloudplatform.connectivity.DefaultHttpDestination; +import java.util.concurrent.CompletableFuture; import javax.annotation.Nonnull; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; @WireMockTest @@ -23,13 +29,9 @@ class AiCoreOpenAiClientTest { @BeforeEach void setup(@Nonnull final WireMockRuntimeInfo server) { - // Create destination pointing to WireMock server final var destination = DefaultHttpDestination.builder(server.getHttpBaseUrl()).build(); + client = AiCoreOpenAiClient.fromDestination(destination, OpenAiModel.GPT_5); - // 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); } @@ -40,18 +42,85 @@ void reset() { } @Test - void testResponseSuccess() { + void testResponseServiceSuccessWithMatchingModel() { final var params = ResponseCreateParams.builder() .input("What is the capital of France?") - .model("gpt-5") + .model(ChatModel.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(); + } + + @Test + void testResponseServiceSuccessWithoutModel() { + final var params = + ResponseCreateParams.builder().input("What is the capital of France?").build(); + final Response response = client.responses().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(() -> client.responses().create(params)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Model mismatch") + .hasMessageContaining("gpt-5") + .hasMessageContaining("gpt-4"); + } + + @Test + void testResponseServiceWithOptions() { + final var service = client.responses(); + + final var modifiedService = + service.withOptions( + builder -> { + // Modify some option + builder.putHeader("X-Custom-Header", "test-value"); + }); + + assertThat(modifiedService).isInstanceOf(AiCoreResponseService.class); + } + + @Test + void testChatCompletionServiceSuccessWithMatchingModel() { + final var params = + ChatCompletionCreateParams.builder() + .model(ChatModel.GPT_5) + .addUserMessage("Say this is a test") + .build(); + + final ChatCompletion response = client.chat().completions().create(params); + assertThat(response).isNotNull(); + } + + @Test + @Disabled("Fails as Async client needs additional wrappers. Maintenance wall.") + void testAsyncChatCompletion() { + final var params = + ChatCompletionCreateParams.builder() + .model(ChatModel.GPT_5) + .addUserMessage("Say this is a test") + .build(); + + final CompletableFuture future = + client.async().chat().completions().create(params); + + final ChatCompletion response = future.join(); + + assertThat(response).isNotNull(); + assertThat(response.choices()).isNotEmpty(); } } diff --git a/foundation-models/openai/src/test/resources/mappings/chatCompletion.json b/foundation-models/openai/src/test/resources/mappings/chatCompletion.json new file mode 100644 index 000000000..bb5358a84 --- /dev/null +++ b/foundation-models/openai/src/test/resources/mappings/chatCompletion.json @@ -0,0 +1,104 @@ +{ + "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