From c1971e6f0bb71192e8f54b61da8adf97bfcc7a17 Mon Sep 17 00:00:00 2001 From: Nourhan Shata Date: Thu, 16 Oct 2025 16:31:17 +0200 Subject: [PATCH 01/26] Reducing service key logs to minimum. --- .../ai/sdk/core/AiCoreServiceKeyAccessor.java | 113 ++++++++++-------- 1 file changed, 60 insertions(+), 53 deletions(-) diff --git a/core/src/main/java/com/sap/ai/sdk/core/AiCoreServiceKeyAccessor.java b/core/src/main/java/com/sap/ai/sdk/core/AiCoreServiceKeyAccessor.java index ee2fc47ba..e4b28a68d 100644 --- a/core/src/main/java/com/sap/ai/sdk/core/AiCoreServiceKeyAccessor.java +++ b/core/src/main/java/com/sap/ai/sdk/core/AiCoreServiceKeyAccessor.java @@ -11,9 +11,12 @@ import io.github.cdimascio.dotenv.Dotenv; import io.github.cdimascio.dotenv.DotenvBuilder; import io.vavr.Lazy; + import java.util.HashMap; import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; import javax.annotation.Nonnull; + import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; import lombok.val; @@ -25,65 +28,69 @@ @Slf4j @AllArgsConstructor class AiCoreServiceKeyAccessor implements ServiceBindingAccessor { - static final String ENV_VAR_KEY = "AICORE_SERVICE_KEY"; - - private final Lazy dotenv; - - AiCoreServiceKeyAccessor() { - this(Dotenv.configure().ignoreIfMissing().ignoreIfMalformed()); - } + static final String ENV_VAR_KEY = "AICORE_SERVICE_KEY"; + private static final AtomicBoolean INFO_LOG_EMITTED = new AtomicBoolean(false); - AiCoreServiceKeyAccessor(@Nonnull final DotenvBuilder dotenvBuilder) { - dotenv = Lazy.of(dotenvBuilder::load); - } + private final Lazy dotenv; - @Nonnull - @Override - public List getServiceBindings() throws ServiceBindingAccessException { - final String serviceKey; - try { - serviceKey = dotenv.get().get(ENV_VAR_KEY); - } catch (Exception e) { - throw new ServiceBindingAccessException("Failed to load service key from environment", e); + AiCoreServiceKeyAccessor() { + this(Dotenv.configure().ignoreIfMissing().ignoreIfMalformed()); } - if (serviceKey == null) { - log.debug("No service key found in environment variable {}", ENV_VAR_KEY); - return List.of(); + + AiCoreServiceKeyAccessor(@Nonnull final DotenvBuilder dotenvBuilder) { + dotenv = Lazy.of(dotenvBuilder::load); } - log.info( - """ - Found a service key in environment variable {}. - Using a service key is recommended for local testing only. - Bind the AI Core service to the application for productive usage. - """, - ENV_VAR_KEY); - val binding = createServiceBinding(serviceKey); - return List.of(binding); - } + @Nonnull + @Override + public List getServiceBindings() throws ServiceBindingAccessException { + final String serviceKey; + try { + serviceKey = dotenv.get().get(ENV_VAR_KEY); + } catch (Exception e) { + throw new ServiceBindingAccessException("Failed to load service key from environment", e); + } + if (serviceKey == null) { + log.debug("No service key found in environment variable {}", ENV_VAR_KEY); + return List.of(); + } + if (INFO_LOG_EMITTED.compareAndSet(false, true)) { + log.info( + """ + Found a service key in environment variable {}. + Using a service key is recommended for local testing only. + Bind the AI Core service to the application for productive usage. + """, + ENV_VAR_KEY); + } - static ServiceBinding createServiceBinding(@Nonnull final String serviceKey) - throws ServiceBindingAccessException { - final HashMap credentials; - try { - credentials = new ObjectMapper().readValue(serviceKey, new TypeReference<>() {}); - } catch (JsonProcessingException e) { - throw new ServiceBindingAccessException( - new AiCoreCredentialsInvalidException( - "Error in parsing service key from the " + ENV_VAR_KEY + " environment variable.", - e)); - } - if (credentials.get("clientid") == null) { - // explicitly check for the client ID to improve the error message - // otherwise we get a rather generic DestinationNotFoundException error that none of the - // loaders matched the binding - throw new ServiceBindingAccessException( - new AiCoreCredentialsInvalidException("Missing clientid in service key")); + val binding = createServiceBinding(serviceKey); + return List.of(binding); } - return new DefaultServiceBindingBuilder() - .withServiceIdentifier(ServiceIdentifier.AI_CORE) - .withCredentials(credentials) - .build(); - } + static ServiceBinding createServiceBinding(@Nonnull final String serviceKey) + throws ServiceBindingAccessException { + final HashMap credentials; + try { + credentials = new ObjectMapper().readValue(serviceKey, new TypeReference<>() { + }); + } catch (JsonProcessingException e) { + throw new ServiceBindingAccessException( + new AiCoreCredentialsInvalidException( + "Error in parsing service key from the " + ENV_VAR_KEY + " environment variable.", + e)); + } + if (credentials.get("clientid") == null) { + // explicitly check for the client ID to improve the error message + // otherwise we get a rather generic DestinationNotFoundException error that none of the + // loaders matched the binding + throw new ServiceBindingAccessException( + new AiCoreCredentialsInvalidException("Missing clientid in service key")); + } + + return new DefaultServiceBindingBuilder() + .withServiceIdentifier(ServiceIdentifier.AI_CORE) + .withCredentials(credentials) + .build(); + } } From 336f3a74d72836bd1f15de05061430583a69e848 Mon Sep 17 00:00:00 2001 From: Nourhan Shata Date: Thu, 23 Oct 2025 15:18:09 +0200 Subject: [PATCH 02/26] Some formatting --- .../ai/sdk/core/AiCoreServiceKeyAccessor.java | 107 +++++++++--------- 1 file changed, 52 insertions(+), 55 deletions(-) diff --git a/core/src/main/java/com/sap/ai/sdk/core/AiCoreServiceKeyAccessor.java b/core/src/main/java/com/sap/ai/sdk/core/AiCoreServiceKeyAccessor.java index e4b28a68d..c01e05862 100644 --- a/core/src/main/java/com/sap/ai/sdk/core/AiCoreServiceKeyAccessor.java +++ b/core/src/main/java/com/sap/ai/sdk/core/AiCoreServiceKeyAccessor.java @@ -11,12 +11,10 @@ import io.github.cdimascio.dotenv.Dotenv; import io.github.cdimascio.dotenv.DotenvBuilder; import io.vavr.Lazy; - import java.util.HashMap; import java.util.List; import java.util.concurrent.atomic.AtomicBoolean; import javax.annotation.Nonnull; - import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; import lombok.val; @@ -28,69 +26,68 @@ @Slf4j @AllArgsConstructor class AiCoreServiceKeyAccessor implements ServiceBindingAccessor { - static final String ENV_VAR_KEY = "AICORE_SERVICE_KEY"; - private static final AtomicBoolean INFO_LOG_EMITTED = new AtomicBoolean(false); + static final String ENV_VAR_KEY = "AICORE_SERVICE_KEY"; + private static final AtomicBoolean INFO_LOG_EMITTED = new AtomicBoolean(false); - private final Lazy dotenv; + private final Lazy dotenv; - AiCoreServiceKeyAccessor() { - this(Dotenv.configure().ignoreIfMissing().ignoreIfMalformed()); - } + AiCoreServiceKeyAccessor() { + this(Dotenv.configure().ignoreIfMissing().ignoreIfMalformed()); + } - AiCoreServiceKeyAccessor(@Nonnull final DotenvBuilder dotenvBuilder) { - dotenv = Lazy.of(dotenvBuilder::load); - } + AiCoreServiceKeyAccessor(@Nonnull final DotenvBuilder dotenvBuilder) { + dotenv = Lazy.of(dotenvBuilder::load); + } - @Nonnull - @Override - public List getServiceBindings() throws ServiceBindingAccessException { - final String serviceKey; - try { - serviceKey = dotenv.get().get(ENV_VAR_KEY); - } catch (Exception e) { - throw new ServiceBindingAccessException("Failed to load service key from environment", e); - } - if (serviceKey == null) { - log.debug("No service key found in environment variable {}", ENV_VAR_KEY); - return List.of(); - } - if (INFO_LOG_EMITTED.compareAndSet(false, true)) { - log.info( - """ + @Nonnull + @Override + public List getServiceBindings() throws ServiceBindingAccessException { + final String serviceKey; + try { + serviceKey = dotenv.get().get(ENV_VAR_KEY); + } catch (Exception e) { + throw new ServiceBindingAccessException("Failed to load service key from environment", e); + } + if (serviceKey == null) { + log.debug("No service key found in environment variable {}", ENV_VAR_KEY); + return List.of(); + } + if (INFO_LOG_EMITTED.compareAndSet(false, true)) { + log.info( + """ Found a service key in environment variable {}. Using a service key is recommended for local testing only. Bind the AI Core service to the application for productive usage. """, - ENV_VAR_KEY); - } - - val binding = createServiceBinding(serviceKey); - return List.of(binding); + ENV_VAR_KEY); } - static ServiceBinding createServiceBinding(@Nonnull final String serviceKey) - throws ServiceBindingAccessException { - final HashMap credentials; - try { - credentials = new ObjectMapper().readValue(serviceKey, new TypeReference<>() { - }); - } catch (JsonProcessingException e) { - throw new ServiceBindingAccessException( - new AiCoreCredentialsInvalidException( - "Error in parsing service key from the " + ENV_VAR_KEY + " environment variable.", - e)); - } - if (credentials.get("clientid") == null) { - // explicitly check for the client ID to improve the error message - // otherwise we get a rather generic DestinationNotFoundException error that none of the - // loaders matched the binding - throw new ServiceBindingAccessException( - new AiCoreCredentialsInvalidException("Missing clientid in service key")); - } + val binding = createServiceBinding(serviceKey); + return List.of(binding); + } - return new DefaultServiceBindingBuilder() - .withServiceIdentifier(ServiceIdentifier.AI_CORE) - .withCredentials(credentials) - .build(); + static ServiceBinding createServiceBinding(@Nonnull final String serviceKey) + throws ServiceBindingAccessException { + final HashMap credentials; + try { + credentials = new ObjectMapper().readValue(serviceKey, new TypeReference<>() {}); + } catch (JsonProcessingException e) { + throw new ServiceBindingAccessException( + new AiCoreCredentialsInvalidException( + "Error in parsing service key from the " + ENV_VAR_KEY + " environment variable.", + e)); } + if (credentials.get("clientid") == null) { + // explicitly check for the client ID to improve the error message + // otherwise we get a rather generic DestinationNotFoundException error that none of the + // loaders matched the binding + throw new ServiceBindingAccessException( + new AiCoreCredentialsInvalidException("Missing clientid in service key")); + } + + return new DefaultServiceBindingBuilder() + .withServiceIdentifier(ServiceIdentifier.AI_CORE) + .withCredentials(credentials) + .build(); + } } From c4cbccc1062f51089943646bf490346f6ba89dad Mon Sep 17 00:00:00 2001 From: Nourhan Shata Date: Thu, 23 Oct 2025 17:19:03 +0200 Subject: [PATCH 03/26] Custom Info Logs per LLM call --- .../core/common/ClientResponseHandler.java | 274 ++++++++++-------- 1 file changed, 146 insertions(+), 128 deletions(-) diff --git a/core/src/main/java/com/sap/ai/sdk/core/common/ClientResponseHandler.java b/core/src/main/java/com/sap/ai/sdk/core/common/ClientResponseHandler.java index 6588898ba..6d6179073 100644 --- a/core/src/main/java/com/sap/ai/sdk/core/common/ClientResponseHandler.java +++ b/core/src/main/java/com/sap/ai/sdk/core/common/ClientResponseHandler.java @@ -6,10 +6,12 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.google.common.annotations.Beta; import io.vavr.control.Try; + import java.nio.charset.StandardCharsets; import java.util.Optional; import javax.annotation.Nonnull; import javax.annotation.Nullable; + import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import lombok.val; @@ -31,142 +33,158 @@ @Slf4j @RequiredArgsConstructor public class ClientResponseHandler - implements HttpClientResponseHandler { - /** The HTTP success response type */ - @Nonnull final Class successType; - - /** The HTTP error response type */ - @Nonnull final Class errorType; - - /** The factory to create exceptions for Http 4xx/5xx responses. */ - @Nonnull final ClientExceptionFactory exceptionFactory; - - /** The parses for JSON responses, will be private once we can remove mixins */ - @Nonnull ObjectMapper objectMapper = getDefaultObjectMapper(); - - /** - * Set the {@link ObjectMapper} to use for parsing JSON responses. - * - * @param jackson The {@link ObjectMapper} to use - * @return the current instance of {@link ClientResponseHandler} with the changed object mapper - */ - @Beta - @Nonnull - public ClientResponseHandler objectMapper(@Nonnull final ObjectMapper jackson) { - objectMapper = jackson; - return this; - } - - /** - * Processes a {@link ClassicHttpResponse} and returns some value corresponding to that response. - * - * @param response The response to process - * @return A model class instantiated from the response - * @throws E in case of a problem or the connection was aborted - */ - @Nonnull - @Override - public T handleResponse(@Nonnull final ClassicHttpResponse response) throws E { - if (response.getCode() >= 300) { - buildAndThrowException(response); - } - return parseSuccess(response); - } - - // The InputStream of the HTTP entity is closed by EntityUtils.toString - @SuppressWarnings("PMD.CloseResource") - @Nonnull - private T parseSuccess(@Nonnull final ClassicHttpResponse response) throws E { - final HttpEntity responseEntity = response.getEntity(); - if (responseEntity == null) { - throw exceptionFactory.build("The HTTP Response is empty").setHttpResponse(response); + implements HttpClientResponseHandler { + /** + * The HTTP success response type + */ + @Nonnull + final Class successType; + + /** + * The HTTP error response type + */ + @Nonnull + final Class errorType; + + /** + * The factory to create exceptions for Http 4xx/5xx responses. + */ + @Nonnull + final ClientExceptionFactory exceptionFactory; + + /** + * The parses for JSON responses, will be private once we can remove mixins + */ + @Nonnull + ObjectMapper objectMapper = getDefaultObjectMapper(); + + /** + * Set the {@link ObjectMapper} to use for parsing JSON responses. + * + * @param jackson The {@link ObjectMapper} to use + * @return the current instance of {@link ClientResponseHandler} with the changed object mapper + */ + @Beta + @Nonnull + public ClientResponseHandler objectMapper(@Nonnull final ObjectMapper jackson) { + objectMapper = jackson; + return this; } - val message = "Failed to parse response entity."; - val content = - tryGetContent(responseEntity) - .getOrElseThrow(e -> exceptionFactory.build(message, e).setHttpResponse(response)); - try { - return objectMapper.readValue(content, successType); - } catch (final JsonProcessingException e) { - log.error("Failed to parse response to type {}", successType); - throw exceptionFactory.build("Failed to parse response", e).setHttpResponse(response); + /** + * Processes a {@link ClassicHttpResponse} and returns some value corresponding to that response. + * + * @param response The response to process + * @return A model class instantiated from the response + * @throws E in case of a problem or the connection was aborted + */ + @Nonnull + @Override + public T handleResponse(@Nonnull final ClassicHttpResponse response) throws E { + if (response.getCode() >= 300) { + buildAndThrowException(response); + } + return parseSuccess(response); } - } - - @Nonnull - private Try tryGetContent(@Nonnull final HttpEntity entity) { - return Try.of(() -> EntityUtils.toString(entity, StandardCharsets.UTF_8)); - } - - /** - * Process the error response and throw an exception. - * - * @param httpResponse The response to process - * @throws ClientException if the response is an error (4xx/5xx) - */ - @SuppressWarnings("PMD.CloseResource") - protected void buildAndThrowException(@Nonnull final ClassicHttpResponse httpResponse) throws E { - - val entity = httpResponse.getEntity(); - - if (entity == null) { - val message = getErrorMessage(httpResponse, "The HTTP Response is empty"); - throw exceptionFactory.build(message).setHttpResponse(httpResponse); + + // The InputStream of the HTTP entity is closed by EntityUtils.toString + @SuppressWarnings("PMD.CloseResource") + @Nonnull + private T parseSuccess(@Nonnull final ClassicHttpResponse response) throws E { + final HttpEntity responseEntity = response.getEntity(); + if (responseEntity == null) { + throw exceptionFactory.build("The HTTP Response is empty").setHttpResponse(response); + } + + val message = "Failed to parse response entity."; + val content = + tryGetContent(responseEntity) + .getOrElseThrow(e -> exceptionFactory.build(message, e).setHttpResponse(response)); + try { + final T value = objectMapper.readValue(content, successType); + log.info( + "LLM request success with duration:{}", + response.getHeaders("x-upstream-service-time")[0].getValue()); + return value; + } catch (final JsonProcessingException e) { + log.error("Failed to parse response to type {}", successType); + throw exceptionFactory.build("Failed to parse response", e).setHttpResponse(response); + } } - val maybeContent = tryGetContent(entity); - if (maybeContent.isFailure()) { - val message = getErrorMessage(httpResponse, "Failed to read the response content"); - val baseException = exceptionFactory.build(message).setHttpResponse(httpResponse); - baseException.addSuppressed(maybeContent.getCause()); - throw baseException; + + @Nonnull + private Try tryGetContent(@Nonnull final HttpEntity entity) { + return Try.of(() -> EntityUtils.toString(entity, StandardCharsets.UTF_8)); } - val content = maybeContent.get(); - if (content == null || content.isBlank()) { - val message = getErrorMessage(httpResponse, "Empty or blank response content"); - throw exceptionFactory.build(message).setHttpResponse(httpResponse); + + /** + * Process the error response and throw an exception. + * + * @param httpResponse The response to process + * @throws ClientException if the response is an error (4xx/5xx) + */ + @SuppressWarnings("PMD.CloseResource") + protected void buildAndThrowException(@Nonnull final ClassicHttpResponse httpResponse) throws E { + + val entity = httpResponse.getEntity(); + + if (entity == null) { + val message = getErrorMessage(httpResponse, "The HTTP Response is empty"); + throw exceptionFactory.build(message).setHttpResponse(httpResponse); + } + val maybeContent = tryGetContent(entity); + if (maybeContent.isFailure()) { + val message = getErrorMessage(httpResponse, "Failed to read the response content"); + val baseException = exceptionFactory.build(message).setHttpResponse(httpResponse); + baseException.addSuppressed(maybeContent.getCause()); + throw baseException; + } + val content = maybeContent.get(); + if (content == null || content.isBlank()) { + val message = getErrorMessage(httpResponse, "Empty or blank response content"); + throw exceptionFactory.build(message).setHttpResponse(httpResponse); + } + + log.error( + "The service responded with an HTTP {} ({})", + httpResponse.getCode(), + httpResponse.getReasonPhrase()); + val contentType = ContentType.parse(entity.getContentType()); + if (!ContentType.APPLICATION_JSON.isSameMimeType(contentType)) { + val message = getErrorMessage(httpResponse, "The response Content-Type is not JSON"); + throw exceptionFactory.build(message).setHttpResponse(httpResponse); + } + + parseErrorResponseAndThrow(content, httpResponse); } - log.error( - "The service responded with an HTTP {} ({})", - httpResponse.getCode(), - httpResponse.getReasonPhrase()); - val contentType = ContentType.parse(entity.getContentType()); - if (!ContentType.APPLICATION_JSON.isSameMimeType(contentType)) { - val message = getErrorMessage(httpResponse, "The response Content-Type is not JSON"); - throw exceptionFactory.build(message).setHttpResponse(httpResponse); + /** + * Parses the JSON content of an error response and throws a module specific exception. + * + * @param content The JSON content of the error response. + * @param httpResponse The HTTP response that contains the error. + * @throws ClientException if the response is an error (4xx/5xx) + */ + protected void parseErrorResponseAndThrow( + @Nonnull final String content, @Nonnull final ClassicHttpResponse httpResponse) throws E { + val maybeClientError = Try.of(() -> objectMapper.readValue(content, errorType)); + if (maybeClientError.isFailure()) { + val message = getErrorMessage(httpResponse, "Failed to parse the JSON error response"); + val baseException = exceptionFactory.build(message).setHttpResponse(httpResponse); + baseException.addSuppressed(maybeClientError.getCause()); + throw baseException; + } + final R clientError = maybeClientError.get(); + val message = getErrorMessage(httpResponse, clientError.getMessage()); + throw exceptionFactory.build(message, clientError, null).setHttpResponse(httpResponse); } - parseErrorResponseAndThrow(content, httpResponse); - } - - /** - * Parses the JSON content of an error response and throws a module specific exception. - * - * @param content The JSON content of the error response. - * @param httpResponse The HTTP response that contains the error. - * @throws ClientException if the response is an error (4xx/5xx) - */ - protected void parseErrorResponseAndThrow( - @Nonnull final String content, @Nonnull final ClassicHttpResponse httpResponse) throws E { - val maybeClientError = Try.of(() -> objectMapper.readValue(content, errorType)); - if (maybeClientError.isFailure()) { - val message = getErrorMessage(httpResponse, "Failed to parse the JSON error response"); - val baseException = exceptionFactory.build(message).setHttpResponse(httpResponse); - baseException.addSuppressed(maybeClientError.getCause()); - throw baseException; + private static String getErrorMessage( + @Nonnull final ClassicHttpResponse rsp, @Nullable final String additionalMessage) { + val baseErrorMessage = + "Request failed with status %d (%s)".formatted(rsp.getCode(), rsp.getReasonPhrase()); + + val message = Optional.ofNullable(additionalMessage).orElse(""); + return message.isEmpty() ? baseErrorMessage : "%s: %s".formatted(baseErrorMessage, message); } - final R clientError = maybeClientError.get(); - val message = getErrorMessage(httpResponse, clientError.getMessage()); - throw exceptionFactory.build(message, clientError, null).setHttpResponse(httpResponse); - } - - private static String getErrorMessage( - @Nonnull final ClassicHttpResponse rsp, @Nullable final String additionalMessage) { - val baseErrorMessage = - "Request failed with status %d (%s)".formatted(rsp.getCode(), rsp.getReasonPhrase()); - - val message = Optional.ofNullable(additionalMessage).orElse(""); - return message.isEmpty() ? baseErrorMessage : "%s: %s".formatted(baseErrorMessage, message); - } } From 8dfb3790639e13b870e47c4e1725f6a29610fd1b Mon Sep 17 00:00:00 2001 From: Nourhan Shata Date: Thu, 23 Oct 2025 19:01:16 +0200 Subject: [PATCH 04/26] Formatting the ClientResponseHandler.java class. --- .../core/common/ClientResponseHandler.java | 278 +++++++++--------- 1 file changed, 132 insertions(+), 146 deletions(-) diff --git a/core/src/main/java/com/sap/ai/sdk/core/common/ClientResponseHandler.java b/core/src/main/java/com/sap/ai/sdk/core/common/ClientResponseHandler.java index 6d6179073..b627e0223 100644 --- a/core/src/main/java/com/sap/ai/sdk/core/common/ClientResponseHandler.java +++ b/core/src/main/java/com/sap/ai/sdk/core/common/ClientResponseHandler.java @@ -6,12 +6,10 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.google.common.annotations.Beta; import io.vavr.control.Try; - import java.nio.charset.StandardCharsets; import java.util.Optional; import javax.annotation.Nonnull; import javax.annotation.Nullable; - import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import lombok.val; @@ -33,158 +31,146 @@ @Slf4j @RequiredArgsConstructor public class ClientResponseHandler - implements HttpClientResponseHandler { - /** - * The HTTP success response type - */ - @Nonnull - final Class successType; - - /** - * The HTTP error response type - */ - @Nonnull - final Class errorType; - - /** - * The factory to create exceptions for Http 4xx/5xx responses. - */ - @Nonnull - final ClientExceptionFactory exceptionFactory; - - /** - * The parses for JSON responses, will be private once we can remove mixins - */ - @Nonnull - ObjectMapper objectMapper = getDefaultObjectMapper(); - - /** - * Set the {@link ObjectMapper} to use for parsing JSON responses. - * - * @param jackson The {@link ObjectMapper} to use - * @return the current instance of {@link ClientResponseHandler} with the changed object mapper - */ - @Beta - @Nonnull - public ClientResponseHandler objectMapper(@Nonnull final ObjectMapper jackson) { - objectMapper = jackson; - return this; + implements HttpClientResponseHandler { + /** The HTTP success response type */ + @Nonnull final Class successType; + + /** The HTTP error response type */ + @Nonnull final Class errorType; + + /** The factory to create exceptions for Http 4xx/5xx responses. */ + @Nonnull final ClientExceptionFactory exceptionFactory; + + /** The parses for JSON responses, will be private once we can remove mixins */ + @Nonnull ObjectMapper objectMapper = getDefaultObjectMapper(); + + /** + * Set the {@link ObjectMapper} to use for parsing JSON responses. + * + * @param jackson The {@link ObjectMapper} to use + * @return the current instance of {@link ClientResponseHandler} with the changed object mapper + */ + @Beta + @Nonnull + public ClientResponseHandler objectMapper(@Nonnull final ObjectMapper jackson) { + objectMapper = jackson; + return this; + } + + /** + * Processes a {@link ClassicHttpResponse} and returns some value corresponding to that response. + * + * @param response The response to process + * @return A model class instantiated from the response + * @throws E in case of a problem or the connection was aborted + */ + @Nonnull + @Override + public T handleResponse(@Nonnull final ClassicHttpResponse response) throws E { + if (response.getCode() >= 300) { + buildAndThrowException(response); } - - /** - * Processes a {@link ClassicHttpResponse} and returns some value corresponding to that response. - * - * @param response The response to process - * @return A model class instantiated from the response - * @throws E in case of a problem or the connection was aborted - */ - @Nonnull - @Override - public T handleResponse(@Nonnull final ClassicHttpResponse response) throws E { - if (response.getCode() >= 300) { - buildAndThrowException(response); - } - return parseSuccess(response); + return parseSuccess(response); + } + + // The InputStream of the HTTP entity is closed by EntityUtils.toString + @SuppressWarnings("PMD.CloseResource") + @Nonnull + private T parseSuccess(@Nonnull final ClassicHttpResponse response) throws E { + final HttpEntity responseEntity = response.getEntity(); + if (responseEntity == null) { + throw exceptionFactory.build("The HTTP Response is empty").setHttpResponse(response); } - // The InputStream of the HTTP entity is closed by EntityUtils.toString - @SuppressWarnings("PMD.CloseResource") - @Nonnull - private T parseSuccess(@Nonnull final ClassicHttpResponse response) throws E { - final HttpEntity responseEntity = response.getEntity(); - if (responseEntity == null) { - throw exceptionFactory.build("The HTTP Response is empty").setHttpResponse(response); - } - - val message = "Failed to parse response entity."; - val content = - tryGetContent(responseEntity) - .getOrElseThrow(e -> exceptionFactory.build(message, e).setHttpResponse(response)); - try { - final T value = objectMapper.readValue(content, successType); - log.info( - "LLM request success with duration:{}", - response.getHeaders("x-upstream-service-time")[0].getValue()); - return value; - } catch (final JsonProcessingException e) { - log.error("Failed to parse response to type {}", successType); - throw exceptionFactory.build("Failed to parse response", e).setHttpResponse(response); - } + val message = "Failed to parse response entity."; + val content = + tryGetContent(responseEntity) + .getOrElseThrow(e -> exceptionFactory.build(message, e).setHttpResponse(response)); + try { + final T value = objectMapper.readValue(content, successType); + log.info( + "LLM request success with duration:{}", + response.getHeaders("x-upstream-service-time")[0].getValue()); + return value; + } catch (final JsonProcessingException e) { + log.error("Failed to parse response to type {}", successType); + throw exceptionFactory.build("Failed to parse response", e).setHttpResponse(response); } - - @Nonnull - private Try tryGetContent(@Nonnull final HttpEntity entity) { - return Try.of(() -> EntityUtils.toString(entity, StandardCharsets.UTF_8)); + } + + @Nonnull + private Try tryGetContent(@Nonnull final HttpEntity entity) { + return Try.of(() -> EntityUtils.toString(entity, StandardCharsets.UTF_8)); + } + + /** + * Process the error response and throw an exception. + * + * @param httpResponse The response to process + * @throws ClientException if the response is an error (4xx/5xx) + */ + @SuppressWarnings("PMD.CloseResource") + protected void buildAndThrowException(@Nonnull final ClassicHttpResponse httpResponse) throws E { + + val entity = httpResponse.getEntity(); + + if (entity == null) { + val message = getErrorMessage(httpResponse, "The HTTP Response is empty"); + throw exceptionFactory.build(message).setHttpResponse(httpResponse); } - - /** - * Process the error response and throw an exception. - * - * @param httpResponse The response to process - * @throws ClientException if the response is an error (4xx/5xx) - */ - @SuppressWarnings("PMD.CloseResource") - protected void buildAndThrowException(@Nonnull final ClassicHttpResponse httpResponse) throws E { - - val entity = httpResponse.getEntity(); - - if (entity == null) { - val message = getErrorMessage(httpResponse, "The HTTP Response is empty"); - throw exceptionFactory.build(message).setHttpResponse(httpResponse); - } - val maybeContent = tryGetContent(entity); - if (maybeContent.isFailure()) { - val message = getErrorMessage(httpResponse, "Failed to read the response content"); - val baseException = exceptionFactory.build(message).setHttpResponse(httpResponse); - baseException.addSuppressed(maybeContent.getCause()); - throw baseException; - } - val content = maybeContent.get(); - if (content == null || content.isBlank()) { - val message = getErrorMessage(httpResponse, "Empty or blank response content"); - throw exceptionFactory.build(message).setHttpResponse(httpResponse); - } - - log.error( - "The service responded with an HTTP {} ({})", - httpResponse.getCode(), - httpResponse.getReasonPhrase()); - val contentType = ContentType.parse(entity.getContentType()); - if (!ContentType.APPLICATION_JSON.isSameMimeType(contentType)) { - val message = getErrorMessage(httpResponse, "The response Content-Type is not JSON"); - throw exceptionFactory.build(message).setHttpResponse(httpResponse); - } - - parseErrorResponseAndThrow(content, httpResponse); + val maybeContent = tryGetContent(entity); + if (maybeContent.isFailure()) { + val message = getErrorMessage(httpResponse, "Failed to read the response content"); + val baseException = exceptionFactory.build(message).setHttpResponse(httpResponse); + baseException.addSuppressed(maybeContent.getCause()); + throw baseException; } - - /** - * Parses the JSON content of an error response and throws a module specific exception. - * - * @param content The JSON content of the error response. - * @param httpResponse The HTTP response that contains the error. - * @throws ClientException if the response is an error (4xx/5xx) - */ - protected void parseErrorResponseAndThrow( - @Nonnull final String content, @Nonnull final ClassicHttpResponse httpResponse) throws E { - val maybeClientError = Try.of(() -> objectMapper.readValue(content, errorType)); - if (maybeClientError.isFailure()) { - val message = getErrorMessage(httpResponse, "Failed to parse the JSON error response"); - val baseException = exceptionFactory.build(message).setHttpResponse(httpResponse); - baseException.addSuppressed(maybeClientError.getCause()); - throw baseException; - } - final R clientError = maybeClientError.get(); - val message = getErrorMessage(httpResponse, clientError.getMessage()); - throw exceptionFactory.build(message, clientError, null).setHttpResponse(httpResponse); + val content = maybeContent.get(); + if (content == null || content.isBlank()) { + val message = getErrorMessage(httpResponse, "Empty or blank response content"); + throw exceptionFactory.build(message).setHttpResponse(httpResponse); } - private static String getErrorMessage( - @Nonnull final ClassicHttpResponse rsp, @Nullable final String additionalMessage) { - val baseErrorMessage = - "Request failed with status %d (%s)".formatted(rsp.getCode(), rsp.getReasonPhrase()); + log.error( + "The service responded with an HTTP {} ({})", + httpResponse.getCode(), + httpResponse.getReasonPhrase()); + val contentType = ContentType.parse(entity.getContentType()); + if (!ContentType.APPLICATION_JSON.isSameMimeType(contentType)) { + val message = getErrorMessage(httpResponse, "The response Content-Type is not JSON"); + throw exceptionFactory.build(message).setHttpResponse(httpResponse); + } - val message = Optional.ofNullable(additionalMessage).orElse(""); - return message.isEmpty() ? baseErrorMessage : "%s: %s".formatted(baseErrorMessage, message); + parseErrorResponseAndThrow(content, httpResponse); + } + + /** + * Parses the JSON content of an error response and throws a module specific exception. + * + * @param content The JSON content of the error response. + * @param httpResponse The HTTP response that contains the error. + * @throws ClientException if the response is an error (4xx/5xx) + */ + protected void parseErrorResponseAndThrow( + @Nonnull final String content, @Nonnull final ClassicHttpResponse httpResponse) throws E { + val maybeClientError = Try.of(() -> objectMapper.readValue(content, errorType)); + if (maybeClientError.isFailure()) { + val message = getErrorMessage(httpResponse, "Failed to parse the JSON error response"); + val baseException = exceptionFactory.build(message).setHttpResponse(httpResponse); + baseException.addSuppressed(maybeClientError.getCause()); + throw baseException; } + final R clientError = maybeClientError.get(); + val message = getErrorMessage(httpResponse, clientError.getMessage()); + throw exceptionFactory.build(message, clientError, null).setHttpResponse(httpResponse); + } + + private static String getErrorMessage( + @Nonnull final ClassicHttpResponse rsp, @Nullable final String additionalMessage) { + val baseErrorMessage = + "Request failed with status %d (%s)".formatted(rsp.getCode(), rsp.getReasonPhrase()); + + val message = Optional.ofNullable(additionalMessage).orElse(""); + return message.isEmpty() ? baseErrorMessage : "%s: %s".formatted(baseErrorMessage, message); + } } From e16b73de62b98715b665957485526d3c5a30eecc Mon Sep 17 00:00:00 2001 From: Nourhan Shata Date: Fri, 24 Oct 2025 15:06:51 +0200 Subject: [PATCH 05/26] Removing unused dependency from grounding pom --- core-services/document-grounding/pom.xml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/core-services/document-grounding/pom.xml b/core-services/document-grounding/pom.xml index 89342cbd4..dd76e0cc0 100644 --- a/core-services/document-grounding/pom.xml +++ b/core-services/document-grounding/pom.xml @@ -76,10 +76,6 @@ com.fasterxml.jackson.core jackson-annotations - - org.slf4j - slf4j-api - com.google.guava guava From 65cd4a879fc1eebe5479655a5c258445287200cb Mon Sep 17 00:00:00 2001 From: Nourhan Shata Date: Fri, 24 Oct 2025 15:34:55 +0200 Subject: [PATCH 06/26] Added Implicit Tool Calls Logging --- .../openai/spring/OpenAiChatModel.java | 326 +++++++++--------- .../spring/OrchestrationChatModel.java | 250 +++++++------- 2 files changed, 292 insertions(+), 284 deletions(-) diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/spring/OpenAiChatModel.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/spring/OpenAiChatModel.java index c31287907..22f59c8cf 100644 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/spring/OpenAiChatModel.java +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/spring/OpenAiChatModel.java @@ -16,12 +16,14 @@ import com.sap.ai.sdk.foundationmodels.openai.generated.model.CreateChatCompletionResponseChoicesInner; import com.sap.ai.sdk.foundationmodels.openai.generated.model.FunctionObject; import io.vavr.control.Option; + import java.math.BigDecimal; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.function.Function; import javax.annotation.Nonnull; + import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import lombok.val; @@ -46,178 +48,180 @@ @RequiredArgsConstructor public class OpenAiChatModel implements ChatModel { - private final OpenAiClient client; - - @Nonnull - private final DefaultToolCallingManager toolCallingManager = - DefaultToolCallingManager.builder().build(); - - @Override - @Nonnull - public ChatResponse call(@Nonnull final Prompt prompt) { - val options = prompt.getOptions(); - var request = new OpenAiChatCompletionRequest(extractMessages(prompt)); - - if (options != null) { - request = extractOptions(request, options); + private final OpenAiClient client; + + @Nonnull + private final DefaultToolCallingManager toolCallingManager = + DefaultToolCallingManager.builder().build(); + + @Override + @Nonnull + public ChatResponse call(@Nonnull final Prompt prompt) { + val options = prompt.getOptions(); + var request = new OpenAiChatCompletionRequest(extractMessages(prompt)); + + if (options != null) { + request = extractOptions(request, options); + } + if ((options instanceof ToolCallingChatOptions toolOptions)) { + request = request.withTools(extractTools(toolOptions)); + } + + val result = client.chatCompletion(request); + val response = new ChatResponse(toGenerations(result)); + + if (options != null && isInternalToolExecutionEnabled(options) && response.hasToolCalls()) { + val toolExecutionResult = toolCallingManager.executeToolCalls(prompt, response); + // Send the tool execution result back to the model. + log.info("Re-invoking model with tool execution results."); + return call(new Prompt(toolExecutionResult.conversationHistory(), options)); + } + return response; } - if ((options instanceof ToolCallingChatOptions toolOptions)) { - request = request.withTools(extractTools(toolOptions)); - } - - val result = client.chatCompletion(request); - val response = new ChatResponse(toGenerations(result)); - if (options != null && isInternalToolExecutionEnabled(options) && response.hasToolCalls()) { - val toolExecutionResult = toolCallingManager.executeToolCalls(prompt, response); - // Send the tool execution result back to the model. - return call(new Prompt(toolExecutionResult.conversationHistory(), options)); + @Override + @Nonnull + public Flux stream(@Nonnull final Prompt prompt) { + val options = prompt.getOptions(); + var request = new OpenAiChatCompletionRequest(extractMessages(prompt)); + + if (options != null) { + request = extractOptions(request, options); + } + if ((options instanceof ToolCallingChatOptions toolOptions)) { + request = request.withTools(extractTools(toolOptions)); + } + + val stream = client.streamChatCompletionDeltas(request); + final Flux flux = + Flux.generate( + stream::iterator, + (iterator, sink) -> { + if (iterator.hasNext()) { + sink.next(iterator.next()); + } else { + sink.complete(); + } + return iterator; + }); + return flux.map( + delta -> { + val assistantMessage = new AssistantMessage(delta.getDeltaContent(), Map.of()); + val metadata = + ChatGenerationMetadata.builder().finishReason(delta.getFinishReason()).build(); + return new ChatResponse(List.of(new Generation(assistantMessage, metadata))); + }); } - return response; - } - @Override - @Nonnull - public Flux stream(@Nonnull final Prompt prompt) { - val options = prompt.getOptions(); - var request = new OpenAiChatCompletionRequest(extractMessages(prompt)); - - if (options != null) { - request = extractOptions(request, options); - } - if ((options instanceof ToolCallingChatOptions toolOptions)) { - request = request.withTools(extractTools(toolOptions)); + private static List extractMessages(final Prompt prompt) { + final List result = new ArrayList<>(); + for (final Message message : prompt.getInstructions()) { + switch (message.getMessageType()) { + case USER -> Option.of(message.getText()).peek(t -> result.add(OpenAiMessage.user(t))); + case SYSTEM -> Option.of(message.getText()).peek(t -> result.add(OpenAiMessage.system(t))); + case ASSISTANT -> addAssistantMessage(result, (AssistantMessage) message); + case TOOL -> addToolMessages(result, (ToolResponseMessage) message); + } + } + return result; } - val stream = client.streamChatCompletionDeltas(request); - final Flux flux = - Flux.generate( - stream::iterator, - (iterator, sink) -> { - if (iterator.hasNext()) { - sink.next(iterator.next()); - } else { - sink.complete(); - } - return iterator; - }); - return flux.map( - delta -> { - val assistantMessage = new AssistantMessage(delta.getDeltaContent(), Map.of()); - val metadata = - ChatGenerationMetadata.builder().finishReason(delta.getFinishReason()).build(); - return new ChatResponse(List.of(new Generation(assistantMessage, metadata))); - }); - } - - private static List extractMessages(final Prompt prompt) { - final List result = new ArrayList<>(); - for (final Message message : prompt.getInstructions()) { - switch (message.getMessageType()) { - case USER -> Option.of(message.getText()).peek(t -> result.add(OpenAiMessage.user(t))); - case SYSTEM -> Option.of(message.getText()).peek(t -> result.add(OpenAiMessage.system(t))); - case ASSISTANT -> addAssistantMessage(result, (AssistantMessage) message); - case TOOL -> addToolMessages(result, (ToolResponseMessage) message); - } - } - return result; - } - - private static void addAssistantMessage( - final List result, final AssistantMessage message) { - if (message.getText() != null) { - result.add(OpenAiMessage.assistant(message.getText())); - return; - } - final Function callTranslate = - toolCall -> OpenAiToolCall.function(toolCall.id(), toolCall.name(), toolCall.arguments()); - val calls = message.getToolCalls().stream().map(callTranslate).toList(); - result.add(OpenAiMessage.assistant(calls)); - } - - private static void addToolMessages( - final List result, final ToolResponseMessage message) { - for (final ToolResponseMessage.ToolResponse response : message.getResponses()) { - result.add(OpenAiMessage.tool(response.responseData(), response.id())); - } - } - - @Nonnull - private static List toGenerations( - @Nonnull final OpenAiChatCompletionResponse result) { - return result.getOriginalResponse().getChoices().stream() - .map(OpenAiChatModel::toGeneration) - .toList(); - } - - @Nonnull - private static Generation toGeneration( - @Nonnull final CreateChatCompletionResponseChoicesInner choice) { - val metadata = - ChatGenerationMetadata.builder().finishReason(choice.getFinishReason().getValue()); - metadata.metadata("index", choice.getIndex()); - if (choice.getLogprobs() != null && !choice.getLogprobs().getContent().isEmpty()) { - metadata.metadata("logprobs", choice.getLogprobs().getContent()); - } - val message = choice.getMessage(); - val calls = new ArrayList(); - if (message.getToolCalls() != null) { - for (final ChatCompletionMessageToolCall c : message.getToolCalls()) { - val fnc = c.getFunction(); - calls.add( - new ToolCall(c.getId(), c.getType().getValue(), fnc.getName(), fnc.getArguments())); - } + private static void addAssistantMessage( + final List result, final AssistantMessage message) { + if (message.getText() != null) { + result.add(OpenAiMessage.assistant(message.getText())); + return; + } + final Function callTranslate = + toolCall -> OpenAiToolCall.function(toolCall.id(), toolCall.name(), toolCall.arguments()); + val calls = message.getToolCalls().stream().map(callTranslate).toList(); + result.add(OpenAiMessage.assistant(calls)); } - val assistantMessage = new AssistantMessage(message.getContent(), Map.of(), calls); - return new Generation(assistantMessage, metadata.build()); - } - - /** - * Adds options to the request. - * - * @param request the request to modify - * @param options the options to extract - * @return the modified request with options applied - */ - @Nonnull - protected static OpenAiChatCompletionRequest extractOptions( - @Nonnull OpenAiChatCompletionRequest request, @Nonnull final ChatOptions options) { - request = request.withStop(options.getStopSequences()).withMaxTokens(options.getMaxTokens()); - if (options.getTemperature() != null) { - request = request.withTemperature(BigDecimal.valueOf(options.getTemperature())); + private static void addToolMessages( + final List result, final ToolResponseMessage message) { + for (final ToolResponseMessage.ToolResponse response : message.getResponses()) { + result.add(OpenAiMessage.tool(response.responseData(), response.id())); + } } - if (options.getTopP() != null) { - request = request.withTopP(BigDecimal.valueOf(options.getTopP())); + + @Nonnull + private static List toGenerations( + @Nonnull final OpenAiChatCompletionResponse result) { + return result.getOriginalResponse().getChoices().stream() + .map(OpenAiChatModel::toGeneration) + .toList(); } - if (options.getPresencePenalty() != null) { - request = request.withPresencePenalty(BigDecimal.valueOf(options.getPresencePenalty())); + + @Nonnull + private static Generation toGeneration( + @Nonnull final CreateChatCompletionResponseChoicesInner choice) { + val metadata = + ChatGenerationMetadata.builder().finishReason(choice.getFinishReason().getValue()); + metadata.metadata("index", choice.getIndex()); + if (choice.getLogprobs() != null && !choice.getLogprobs().getContent().isEmpty()) { + metadata.metadata("logprobs", choice.getLogprobs().getContent()); + } + val message = choice.getMessage(); + val calls = new ArrayList(); + if (message.getToolCalls() != null) { + for (final ChatCompletionMessageToolCall c : message.getToolCalls()) { + val fnc = c.getFunction(); + calls.add( + new ToolCall(c.getId(), c.getType().getValue(), fnc.getName(), fnc.getArguments())); + } + } + + val assistantMessage = new AssistantMessage(message.getContent(), Map.of(), calls); + return new Generation(assistantMessage, metadata.build()); } - if (options.getFrequencyPenalty() != null) { - request = request.withFrequencyPenalty(BigDecimal.valueOf(options.getFrequencyPenalty())); + + /** + * Adds options to the request. + * + * @param request the request to modify + * @param options the options to extract + * @return the modified request with options applied + */ + @Nonnull + protected static OpenAiChatCompletionRequest extractOptions( + @Nonnull OpenAiChatCompletionRequest request, @Nonnull final ChatOptions options) { + request = request.withStop(options.getStopSequences()).withMaxTokens(options.getMaxTokens()); + if (options.getTemperature() != null) { + request = request.withTemperature(BigDecimal.valueOf(options.getTemperature())); + } + if (options.getTopP() != null) { + request = request.withTopP(BigDecimal.valueOf(options.getTopP())); + } + if (options.getPresencePenalty() != null) { + request = request.withPresencePenalty(BigDecimal.valueOf(options.getPresencePenalty())); + } + if (options.getFrequencyPenalty() != null) { + request = request.withFrequencyPenalty(BigDecimal.valueOf(options.getFrequencyPenalty())); + } + return request; } - return request; - } - - private static List extractTools(final ToolCallingChatOptions options) { - val tools = new ArrayList(); - for (val toolCallback : options.getToolCallbacks()) { - val toolDefinition = toolCallback.getToolDefinition(); - try { - final Map params = - new ObjectMapper().readValue(toolDefinition.inputSchema(), new TypeReference<>() {}); - val toolType = ChatCompletionTool.TypeEnum.FUNCTION; - val toolFunction = - new FunctionObject() - .name(toolDefinition.name()) - .description(toolDefinition.description()) - .parameters(params); - val tool = new ChatCompletionTool().type(toolType).function(toolFunction); - tools.add(tool); - } catch (JsonProcessingException e) { - log.warn("Failed to add tool to the chat request: {}", e.getMessage()); - } + + private static List extractTools(final ToolCallingChatOptions options) { + val tools = new ArrayList(); + for (val toolCallback : options.getToolCallbacks()) { + val toolDefinition = toolCallback.getToolDefinition(); + try { + final Map params = + new ObjectMapper().readValue(toolDefinition.inputSchema(), new TypeReference<>() { + }); + val toolType = ChatCompletionTool.TypeEnum.FUNCTION; + val toolFunction = + new FunctionObject() + .name(toolDefinition.name()) + .description(toolDefinition.description()) + .parameters(params); + val tool = new ChatCompletionTool().type(toolType).function(toolFunction); + tools.add(tool); + } catch (JsonProcessingException e) { + log.warn("Failed to add tool to the chat request: {}", e.getMessage()); + } + } + return tools; } - return tools; - } } diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/spring/OrchestrationChatModel.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/spring/OrchestrationChatModel.java index 23f51833b..eb4750324 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/spring/OrchestrationChatModel.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/spring/OrchestrationChatModel.java @@ -12,10 +12,12 @@ import com.sap.ai.sdk.orchestration.UserMessage; import com.sap.ai.sdk.orchestration.model.MessageToolCall; import com.sap.ai.sdk.orchestration.model.MessageToolCallFunction; + import java.util.List; import java.util.Map; import java.util.function.Function; import javax.annotation.Nonnull; + import lombok.extern.slf4j.Slf4j; import lombok.val; import org.springframework.ai.chat.messages.AssistantMessage.ToolCall; @@ -35,129 +37,131 @@ */ @Slf4j public class OrchestrationChatModel implements ChatModel { - @Nonnull private final OrchestrationClient client; - - @Nonnull - private final DefaultToolCallingManager toolCallingManager = - DefaultToolCallingManager.builder().build(); - - /** - * Default constructor. - * - * @since 1.2.0 - */ - public OrchestrationChatModel() { - this(new OrchestrationClient()); - } - - /** - * Constructor with a custom client. - * - * @param client The custom client to use. - * @since 1.2.0 - */ - public OrchestrationChatModel(@Nonnull final OrchestrationClient client) { - this.client = client; - } - - @Nonnull - @Override - public ChatResponse call(@Nonnull final Prompt prompt) { - if (prompt.getOptions() instanceof OrchestrationChatOptions options) { - - val orchestrationPrompt = toOrchestrationPrompt(prompt); - val response = - new OrchestrationSpringChatResponse( - client.chatCompletion(orchestrationPrompt, options.getConfig())); - - if (ToolCallingChatOptions.isInternalToolExecutionEnabled(prompt.getOptions()) - && response.hasToolCalls()) { - val toolExecutionResult = toolCallingManager.executeToolCalls(prompt, response); - // Send the tool execution result back to the model. - return call(new Prompt(toolExecutionResult.conversationHistory(), prompt.getOptions())); - } - return response; + @Nonnull + private final OrchestrationClient client; + + @Nonnull + private final DefaultToolCallingManager toolCallingManager = + DefaultToolCallingManager.builder().build(); + + /** + * Default constructor. + * + * @since 1.2.0 + */ + public OrchestrationChatModel() { + this(new OrchestrationClient()); + } + + /** + * Constructor with a custom client. + * + * @param client The custom client to use. + * @since 1.2.0 + */ + public OrchestrationChatModel(@Nonnull final OrchestrationClient client) { + this.client = client; } - throw new IllegalArgumentException( - "Please add OrchestrationChatOptions to the Prompt: new Prompt(\"message\", new OrchestrationChatOptions(config))"); - } - - @Override - @Nonnull - public Flux stream(@Nonnull final Prompt prompt) { - - if (prompt.getOptions() instanceof OrchestrationChatOptions options) { - - val orchestrationPrompt = toOrchestrationPrompt(prompt); - val request = toCompletionPostRequest(orchestrationPrompt, options.getConfig()); - val stream = client.streamChatCompletionDeltas(request); - - final Flux flux = - Flux.generate( - stream::iterator, - (iterator, sink) -> { - if (iterator.hasNext()) { - sink.next(iterator.next()); - } else { - sink.complete(); - } - return iterator; - }); - return flux.map(OrchestrationSpringChatDelta::new); + + @Nonnull + @Override + public ChatResponse call(@Nonnull final Prompt prompt) { + if (prompt.getOptions() instanceof OrchestrationChatOptions options) { + + val orchestrationPrompt = toOrchestrationPrompt(prompt); + val response = + new OrchestrationSpringChatResponse( + client.chatCompletion(orchestrationPrompt, options.getConfig())); + + if (ToolCallingChatOptions.isInternalToolExecutionEnabled(prompt.getOptions()) + && response.hasToolCalls()) { + val toolExecutionResult = toolCallingManager.executeToolCalls(prompt, response); + // Send the tool execution result back to the model. + log.info("Re-invoking model with tool execution results."); + return call(new Prompt(toolExecutionResult.conversationHistory(), prompt.getOptions())); + } + return response; + } + throw new IllegalArgumentException( + "Please add OrchestrationChatOptions to the Prompt: new Prompt(\"message\", new OrchestrationChatOptions(config))"); + } + + @Override + @Nonnull + public Flux stream(@Nonnull final Prompt prompt) { + + if (prompt.getOptions() instanceof OrchestrationChatOptions options) { + + val orchestrationPrompt = toOrchestrationPrompt(prompt); + val request = toCompletionPostRequest(orchestrationPrompt, options.getConfig()); + val stream = client.streamChatCompletionDeltas(request); + + final Flux flux = + Flux.generate( + stream::iterator, + (iterator, sink) -> { + if (iterator.hasNext()) { + sink.next(iterator.next()); + } else { + sink.complete(); + } + return iterator; + }); + return flux.map(OrchestrationSpringChatDelta::new); + } + throw new IllegalArgumentException( + "Please add OrchestrationChatOptions to the Prompt: new Prompt(\"message\", new OrchestrationChatOptions(config))"); + } + + @Nonnull + private OrchestrationPrompt toOrchestrationPrompt(@Nonnull final Prompt prompt) { + val messages = toOrchestrationMessages(prompt.getInstructions()); + return new OrchestrationPrompt(Map.of(), messages); + } + + @Nonnull + private static com.sap.ai.sdk.orchestration.Message[] toOrchestrationMessages( + @Nonnull final List messages) { + final Function> mapper = + msg -> + switch (msg.getMessageType()) { + case SYSTEM: + yield List.of(new SystemMessage(msg.getText())); + case USER: + yield List.of(new UserMessage(msg.getText())); + case ASSISTANT: + val assistantMessage = new AssistantMessage(msg.getText()); + val springToolCalls = + ((org.springframework.ai.chat.messages.AssistantMessage) msg).getToolCalls(); + if (springToolCalls != null && !springToolCalls.isEmpty()) { + final List sdkToolCalls = + springToolCalls.stream() + .map(OrchestrationChatModel::toOrchestrationToolCall) + .toList(); + yield List.of(assistantMessage.withToolCalls(sdkToolCalls)); + } + yield List.of(assistantMessage); + case TOOL: + val toolResponses = ((ToolResponseMessage) msg).getResponses(); + yield toolResponses.stream() + .map( + r -> + (com.sap.ai.sdk.orchestration.Message) + new ToolMessage(r.id(), r.responseData())) + .toList(); + }; + return messages.stream() + .map(mapper) + .flatMap(List::stream) + .toArray(com.sap.ai.sdk.orchestration.Message[]::new); + } + + @Nonnull + private static MessageToolCall toOrchestrationToolCall(@Nonnull final ToolCall toolCall) { + return MessageToolCall.create() + .id(toolCall.id()) + .type(FUNCTION) + .function( + MessageToolCallFunction.create().name(toolCall.name()).arguments(toolCall.arguments())); } - throw new IllegalArgumentException( - "Please add OrchestrationChatOptions to the Prompt: new Prompt(\"message\", new OrchestrationChatOptions(config))"); - } - - @Nonnull - private OrchestrationPrompt toOrchestrationPrompt(@Nonnull final Prompt prompt) { - val messages = toOrchestrationMessages(prompt.getInstructions()); - return new OrchestrationPrompt(Map.of(), messages); - } - - @Nonnull - private static com.sap.ai.sdk.orchestration.Message[] toOrchestrationMessages( - @Nonnull final List messages) { - final Function> mapper = - msg -> - switch (msg.getMessageType()) { - case SYSTEM: - yield List.of(new SystemMessage(msg.getText())); - case USER: - yield List.of(new UserMessage(msg.getText())); - case ASSISTANT: - val assistantMessage = new AssistantMessage(msg.getText()); - val springToolCalls = - ((org.springframework.ai.chat.messages.AssistantMessage) msg).getToolCalls(); - if (springToolCalls != null && !springToolCalls.isEmpty()) { - final List sdkToolCalls = - springToolCalls.stream() - .map(OrchestrationChatModel::toOrchestrationToolCall) - .toList(); - yield List.of(assistantMessage.withToolCalls(sdkToolCalls)); - } - yield List.of(assistantMessage); - case TOOL: - val toolResponses = ((ToolResponseMessage) msg).getResponses(); - yield toolResponses.stream() - .map( - r -> - (com.sap.ai.sdk.orchestration.Message) - new ToolMessage(r.id(), r.responseData())) - .toList(); - }; - return messages.stream() - .map(mapper) - .flatMap(List::stream) - .toArray(com.sap.ai.sdk.orchestration.Message[]::new); - } - - @Nonnull - private static MessageToolCall toOrchestrationToolCall(@Nonnull final ToolCall toolCall) { - return MessageToolCall.create() - .id(toolCall.id()) - .type(FUNCTION) - .function( - MessageToolCallFunction.create().name(toolCall.name()).arguments(toolCall.arguments())); - } } From 878229f0141f7c52f88b796070565e453653da2c Mon Sep 17 00:00:00 2001 From: Nourhan Shata Date: Fri, 24 Oct 2025 15:35:40 +0200 Subject: [PATCH 07/26] Added LLM Calls Logging --- .../core/common/ClientResponseHandler.java | 278 +++++++++--------- 1 file changed, 146 insertions(+), 132 deletions(-) diff --git a/core/src/main/java/com/sap/ai/sdk/core/common/ClientResponseHandler.java b/core/src/main/java/com/sap/ai/sdk/core/common/ClientResponseHandler.java index b627e0223..6d6179073 100644 --- a/core/src/main/java/com/sap/ai/sdk/core/common/ClientResponseHandler.java +++ b/core/src/main/java/com/sap/ai/sdk/core/common/ClientResponseHandler.java @@ -6,10 +6,12 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.google.common.annotations.Beta; import io.vavr.control.Try; + import java.nio.charset.StandardCharsets; import java.util.Optional; import javax.annotation.Nonnull; import javax.annotation.Nullable; + import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import lombok.val; @@ -31,146 +33,158 @@ @Slf4j @RequiredArgsConstructor public class ClientResponseHandler - implements HttpClientResponseHandler { - /** The HTTP success response type */ - @Nonnull final Class successType; - - /** The HTTP error response type */ - @Nonnull final Class errorType; - - /** The factory to create exceptions for Http 4xx/5xx responses. */ - @Nonnull final ClientExceptionFactory exceptionFactory; - - /** The parses for JSON responses, will be private once we can remove mixins */ - @Nonnull ObjectMapper objectMapper = getDefaultObjectMapper(); - - /** - * Set the {@link ObjectMapper} to use for parsing JSON responses. - * - * @param jackson The {@link ObjectMapper} to use - * @return the current instance of {@link ClientResponseHandler} with the changed object mapper - */ - @Beta - @Nonnull - public ClientResponseHandler objectMapper(@Nonnull final ObjectMapper jackson) { - objectMapper = jackson; - return this; - } - - /** - * Processes a {@link ClassicHttpResponse} and returns some value corresponding to that response. - * - * @param response The response to process - * @return A model class instantiated from the response - * @throws E in case of a problem or the connection was aborted - */ - @Nonnull - @Override - public T handleResponse(@Nonnull final ClassicHttpResponse response) throws E { - if (response.getCode() >= 300) { - buildAndThrowException(response); - } - return parseSuccess(response); - } - - // The InputStream of the HTTP entity is closed by EntityUtils.toString - @SuppressWarnings("PMD.CloseResource") - @Nonnull - private T parseSuccess(@Nonnull final ClassicHttpResponse response) throws E { - final HttpEntity responseEntity = response.getEntity(); - if (responseEntity == null) { - throw exceptionFactory.build("The HTTP Response is empty").setHttpResponse(response); + implements HttpClientResponseHandler { + /** + * The HTTP success response type + */ + @Nonnull + final Class successType; + + /** + * The HTTP error response type + */ + @Nonnull + final Class errorType; + + /** + * The factory to create exceptions for Http 4xx/5xx responses. + */ + @Nonnull + final ClientExceptionFactory exceptionFactory; + + /** + * The parses for JSON responses, will be private once we can remove mixins + */ + @Nonnull + ObjectMapper objectMapper = getDefaultObjectMapper(); + + /** + * Set the {@link ObjectMapper} to use for parsing JSON responses. + * + * @param jackson The {@link ObjectMapper} to use + * @return the current instance of {@link ClientResponseHandler} with the changed object mapper + */ + @Beta + @Nonnull + public ClientResponseHandler objectMapper(@Nonnull final ObjectMapper jackson) { + objectMapper = jackson; + return this; } - val message = "Failed to parse response entity."; - val content = - tryGetContent(responseEntity) - .getOrElseThrow(e -> exceptionFactory.build(message, e).setHttpResponse(response)); - try { - final T value = objectMapper.readValue(content, successType); - log.info( - "LLM request success with duration:{}", - response.getHeaders("x-upstream-service-time")[0].getValue()); - return value; - } catch (final JsonProcessingException e) { - log.error("Failed to parse response to type {}", successType); - throw exceptionFactory.build("Failed to parse response", e).setHttpResponse(response); + /** + * Processes a {@link ClassicHttpResponse} and returns some value corresponding to that response. + * + * @param response The response to process + * @return A model class instantiated from the response + * @throws E in case of a problem or the connection was aborted + */ + @Nonnull + @Override + public T handleResponse(@Nonnull final ClassicHttpResponse response) throws E { + if (response.getCode() >= 300) { + buildAndThrowException(response); + } + return parseSuccess(response); } - } - - @Nonnull - private Try tryGetContent(@Nonnull final HttpEntity entity) { - return Try.of(() -> EntityUtils.toString(entity, StandardCharsets.UTF_8)); - } - - /** - * Process the error response and throw an exception. - * - * @param httpResponse The response to process - * @throws ClientException if the response is an error (4xx/5xx) - */ - @SuppressWarnings("PMD.CloseResource") - protected void buildAndThrowException(@Nonnull final ClassicHttpResponse httpResponse) throws E { - - val entity = httpResponse.getEntity(); - - if (entity == null) { - val message = getErrorMessage(httpResponse, "The HTTP Response is empty"); - throw exceptionFactory.build(message).setHttpResponse(httpResponse); + + // The InputStream of the HTTP entity is closed by EntityUtils.toString + @SuppressWarnings("PMD.CloseResource") + @Nonnull + private T parseSuccess(@Nonnull final ClassicHttpResponse response) throws E { + final HttpEntity responseEntity = response.getEntity(); + if (responseEntity == null) { + throw exceptionFactory.build("The HTTP Response is empty").setHttpResponse(response); + } + + val message = "Failed to parse response entity."; + val content = + tryGetContent(responseEntity) + .getOrElseThrow(e -> exceptionFactory.build(message, e).setHttpResponse(response)); + try { + final T value = objectMapper.readValue(content, successType); + log.info( + "LLM request success with duration:{}", + response.getHeaders("x-upstream-service-time")[0].getValue()); + return value; + } catch (final JsonProcessingException e) { + log.error("Failed to parse response to type {}", successType); + throw exceptionFactory.build("Failed to parse response", e).setHttpResponse(response); + } } - val maybeContent = tryGetContent(entity); - if (maybeContent.isFailure()) { - val message = getErrorMessage(httpResponse, "Failed to read the response content"); - val baseException = exceptionFactory.build(message).setHttpResponse(httpResponse); - baseException.addSuppressed(maybeContent.getCause()); - throw baseException; + + @Nonnull + private Try tryGetContent(@Nonnull final HttpEntity entity) { + return Try.of(() -> EntityUtils.toString(entity, StandardCharsets.UTF_8)); } - val content = maybeContent.get(); - if (content == null || content.isBlank()) { - val message = getErrorMessage(httpResponse, "Empty or blank response content"); - throw exceptionFactory.build(message).setHttpResponse(httpResponse); + + /** + * Process the error response and throw an exception. + * + * @param httpResponse The response to process + * @throws ClientException if the response is an error (4xx/5xx) + */ + @SuppressWarnings("PMD.CloseResource") + protected void buildAndThrowException(@Nonnull final ClassicHttpResponse httpResponse) throws E { + + val entity = httpResponse.getEntity(); + + if (entity == null) { + val message = getErrorMessage(httpResponse, "The HTTP Response is empty"); + throw exceptionFactory.build(message).setHttpResponse(httpResponse); + } + val maybeContent = tryGetContent(entity); + if (maybeContent.isFailure()) { + val message = getErrorMessage(httpResponse, "Failed to read the response content"); + val baseException = exceptionFactory.build(message).setHttpResponse(httpResponse); + baseException.addSuppressed(maybeContent.getCause()); + throw baseException; + } + val content = maybeContent.get(); + if (content == null || content.isBlank()) { + val message = getErrorMessage(httpResponse, "Empty or blank response content"); + throw exceptionFactory.build(message).setHttpResponse(httpResponse); + } + + log.error( + "The service responded with an HTTP {} ({})", + httpResponse.getCode(), + httpResponse.getReasonPhrase()); + val contentType = ContentType.parse(entity.getContentType()); + if (!ContentType.APPLICATION_JSON.isSameMimeType(contentType)) { + val message = getErrorMessage(httpResponse, "The response Content-Type is not JSON"); + throw exceptionFactory.build(message).setHttpResponse(httpResponse); + } + + parseErrorResponseAndThrow(content, httpResponse); } - log.error( - "The service responded with an HTTP {} ({})", - httpResponse.getCode(), - httpResponse.getReasonPhrase()); - val contentType = ContentType.parse(entity.getContentType()); - if (!ContentType.APPLICATION_JSON.isSameMimeType(contentType)) { - val message = getErrorMessage(httpResponse, "The response Content-Type is not JSON"); - throw exceptionFactory.build(message).setHttpResponse(httpResponse); + /** + * Parses the JSON content of an error response and throws a module specific exception. + * + * @param content The JSON content of the error response. + * @param httpResponse The HTTP response that contains the error. + * @throws ClientException if the response is an error (4xx/5xx) + */ + protected void parseErrorResponseAndThrow( + @Nonnull final String content, @Nonnull final ClassicHttpResponse httpResponse) throws E { + val maybeClientError = Try.of(() -> objectMapper.readValue(content, errorType)); + if (maybeClientError.isFailure()) { + val message = getErrorMessage(httpResponse, "Failed to parse the JSON error response"); + val baseException = exceptionFactory.build(message).setHttpResponse(httpResponse); + baseException.addSuppressed(maybeClientError.getCause()); + throw baseException; + } + final R clientError = maybeClientError.get(); + val message = getErrorMessage(httpResponse, clientError.getMessage()); + throw exceptionFactory.build(message, clientError, null).setHttpResponse(httpResponse); } - parseErrorResponseAndThrow(content, httpResponse); - } - - /** - * Parses the JSON content of an error response and throws a module specific exception. - * - * @param content The JSON content of the error response. - * @param httpResponse The HTTP response that contains the error. - * @throws ClientException if the response is an error (4xx/5xx) - */ - protected void parseErrorResponseAndThrow( - @Nonnull final String content, @Nonnull final ClassicHttpResponse httpResponse) throws E { - val maybeClientError = Try.of(() -> objectMapper.readValue(content, errorType)); - if (maybeClientError.isFailure()) { - val message = getErrorMessage(httpResponse, "Failed to parse the JSON error response"); - val baseException = exceptionFactory.build(message).setHttpResponse(httpResponse); - baseException.addSuppressed(maybeClientError.getCause()); - throw baseException; + private static String getErrorMessage( + @Nonnull final ClassicHttpResponse rsp, @Nullable final String additionalMessage) { + val baseErrorMessage = + "Request failed with status %d (%s)".formatted(rsp.getCode(), rsp.getReasonPhrase()); + + val message = Optional.ofNullable(additionalMessage).orElse(""); + return message.isEmpty() ? baseErrorMessage : "%s: %s".formatted(baseErrorMessage, message); } - final R clientError = maybeClientError.get(); - val message = getErrorMessage(httpResponse, clientError.getMessage()); - throw exceptionFactory.build(message, clientError, null).setHttpResponse(httpResponse); - } - - private static String getErrorMessage( - @Nonnull final ClassicHttpResponse rsp, @Nullable final String additionalMessage) { - val baseErrorMessage = - "Request failed with status %d (%s)".formatted(rsp.getCode(), rsp.getReasonPhrase()); - - val message = Optional.ofNullable(additionalMessage).orElse(""); - return message.isEmpty() ? baseErrorMessage : "%s: %s".formatted(baseErrorMessage, message); - } } From 52540e9e76d28d6378e4c62a839927f0f3575c06 Mon Sep 17 00:00:00 2001 From: Nourhan Shata Date: Fri, 24 Oct 2025 15:36:08 +0200 Subject: [PATCH 08/26] Some Formatting --- .../ai/sdk/orchestration/spring/OrchestrationChatModelTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/orchestration/src/test/java/com/sap/ai/sdk/orchestration/spring/OrchestrationChatModelTest.java b/orchestration/src/test/java/com/sap/ai/sdk/orchestration/spring/OrchestrationChatModelTest.java index ca16b78a6..3379ba575 100644 --- a/orchestration/src/test/java/com/sap/ai/sdk/orchestration/spring/OrchestrationChatModelTest.java +++ b/orchestration/src/test/java/com/sap/ai/sdk/orchestration/spring/OrchestrationChatModelTest.java @@ -84,7 +84,7 @@ void testCompletion() { aResponse() .withBodyFile("templatingResponse.json") .withHeader("Content-Type", "application/json"))); - val result = client.call(prompt); + val result = client. call(prompt); assertThat(result).isNotNull(); assertThat(result.getResult().getOutput().getText()).isNotEmpty(); From 14de56f7c6dfe66df0a4ac3a3e3f14ee675054b7 Mon Sep 17 00:00:00 2001 From: Nourhan Shata Date: Mon, 27 Oct 2025 13:53:57 +0100 Subject: [PATCH 09/26] Some Formatting --- .../ai/sdk/orchestration/spring/OrchestrationChatModelTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/orchestration/src/test/java/com/sap/ai/sdk/orchestration/spring/OrchestrationChatModelTest.java b/orchestration/src/test/java/com/sap/ai/sdk/orchestration/spring/OrchestrationChatModelTest.java index 3379ba575..ca16b78a6 100644 --- a/orchestration/src/test/java/com/sap/ai/sdk/orchestration/spring/OrchestrationChatModelTest.java +++ b/orchestration/src/test/java/com/sap/ai/sdk/orchestration/spring/OrchestrationChatModelTest.java @@ -84,7 +84,7 @@ void testCompletion() { aResponse() .withBodyFile("templatingResponse.json") .withHeader("Content-Type", "application/json"))); - val result = client. call(prompt); + val result = client.call(prompt); assertThat(result).isNotNull(); assertThat(result.getResult().getOutput().getText()).isNotEmpty(); From 5792a518350432e87cf3a3d8b31cf95d63931e59 Mon Sep 17 00:00:00 2001 From: Nourhan Shata Date: Mon, 27 Oct 2025 13:54:49 +0100 Subject: [PATCH 10/26] Some Formatting --- .../spring/OrchestrationChatModel.java | 251 +++++++++--------- 1 file changed, 124 insertions(+), 127 deletions(-) diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/spring/OrchestrationChatModel.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/spring/OrchestrationChatModel.java index eb4750324..15d7667a8 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/spring/OrchestrationChatModel.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/spring/OrchestrationChatModel.java @@ -12,12 +12,10 @@ import com.sap.ai.sdk.orchestration.UserMessage; import com.sap.ai.sdk.orchestration.model.MessageToolCall; import com.sap.ai.sdk.orchestration.model.MessageToolCallFunction; - import java.util.List; import java.util.Map; import java.util.function.Function; import javax.annotation.Nonnull; - import lombok.extern.slf4j.Slf4j; import lombok.val; import org.springframework.ai.chat.messages.AssistantMessage.ToolCall; @@ -37,131 +35,130 @@ */ @Slf4j public class OrchestrationChatModel implements ChatModel { - @Nonnull - private final OrchestrationClient client; - - @Nonnull - private final DefaultToolCallingManager toolCallingManager = - DefaultToolCallingManager.builder().build(); - - /** - * Default constructor. - * - * @since 1.2.0 - */ - public OrchestrationChatModel() { - this(new OrchestrationClient()); - } - - /** - * Constructor with a custom client. - * - * @param client The custom client to use. - * @since 1.2.0 - */ - public OrchestrationChatModel(@Nonnull final OrchestrationClient client) { - this.client = client; + @Nonnull private final OrchestrationClient client; + + @Nonnull + private final DefaultToolCallingManager toolCallingManager = + DefaultToolCallingManager.builder().build(); + + /** + * Default constructor. + * + * @since 1.2.0 + */ + public OrchestrationChatModel() { + this(new OrchestrationClient()); + } + + /** + * Constructor with a custom client. + * + * @param client The custom client to use. + * @since 1.2.0 + */ + public OrchestrationChatModel(@Nonnull final OrchestrationClient client) { + this.client = client; + } + + @Nonnull + @Override + public ChatResponse call(@Nonnull final Prompt prompt) { + if (prompt.getOptions() instanceof OrchestrationChatOptions options) { + + val orchestrationPrompt = toOrchestrationPrompt(prompt); + val response = + new OrchestrationSpringChatResponse( + client.chatCompletion(orchestrationPrompt, options.getConfig())); + + if (ToolCallingChatOptions.isInternalToolExecutionEnabled(prompt.getOptions()) + && response.hasToolCalls()) { + val toolExecutionResult = toolCallingManager.executeToolCalls(prompt, response); + // Send the tool execution result back to the model. + log.info("Re-invoking model with tool execution results."); + return call(new Prompt(toolExecutionResult.conversationHistory(), prompt.getOptions())); + } + return response; } - - @Nonnull - @Override - public ChatResponse call(@Nonnull final Prompt prompt) { - if (prompt.getOptions() instanceof OrchestrationChatOptions options) { - - val orchestrationPrompt = toOrchestrationPrompt(prompt); - val response = - new OrchestrationSpringChatResponse( - client.chatCompletion(orchestrationPrompt, options.getConfig())); - - if (ToolCallingChatOptions.isInternalToolExecutionEnabled(prompt.getOptions()) - && response.hasToolCalls()) { - val toolExecutionResult = toolCallingManager.executeToolCalls(prompt, response); - // Send the tool execution result back to the model. - log.info("Re-invoking model with tool execution results."); - return call(new Prompt(toolExecutionResult.conversationHistory(), prompt.getOptions())); - } - return response; - } - throw new IllegalArgumentException( - "Please add OrchestrationChatOptions to the Prompt: new Prompt(\"message\", new OrchestrationChatOptions(config))"); - } - - @Override - @Nonnull - public Flux stream(@Nonnull final Prompt prompt) { - - if (prompt.getOptions() instanceof OrchestrationChatOptions options) { - - val orchestrationPrompt = toOrchestrationPrompt(prompt); - val request = toCompletionPostRequest(orchestrationPrompt, options.getConfig()); - val stream = client.streamChatCompletionDeltas(request); - - final Flux flux = - Flux.generate( - stream::iterator, - (iterator, sink) -> { - if (iterator.hasNext()) { - sink.next(iterator.next()); - } else { - sink.complete(); - } - return iterator; - }); - return flux.map(OrchestrationSpringChatDelta::new); - } - throw new IllegalArgumentException( - "Please add OrchestrationChatOptions to the Prompt: new Prompt(\"message\", new OrchestrationChatOptions(config))"); - } - - @Nonnull - private OrchestrationPrompt toOrchestrationPrompt(@Nonnull final Prompt prompt) { - val messages = toOrchestrationMessages(prompt.getInstructions()); - return new OrchestrationPrompt(Map.of(), messages); - } - - @Nonnull - private static com.sap.ai.sdk.orchestration.Message[] toOrchestrationMessages( - @Nonnull final List messages) { - final Function> mapper = - msg -> - switch (msg.getMessageType()) { - case SYSTEM: - yield List.of(new SystemMessage(msg.getText())); - case USER: - yield List.of(new UserMessage(msg.getText())); - case ASSISTANT: - val assistantMessage = new AssistantMessage(msg.getText()); - val springToolCalls = - ((org.springframework.ai.chat.messages.AssistantMessage) msg).getToolCalls(); - if (springToolCalls != null && !springToolCalls.isEmpty()) { - final List sdkToolCalls = - springToolCalls.stream() - .map(OrchestrationChatModel::toOrchestrationToolCall) - .toList(); - yield List.of(assistantMessage.withToolCalls(sdkToolCalls)); - } - yield List.of(assistantMessage); - case TOOL: - val toolResponses = ((ToolResponseMessage) msg).getResponses(); - yield toolResponses.stream() - .map( - r -> - (com.sap.ai.sdk.orchestration.Message) - new ToolMessage(r.id(), r.responseData())) - .toList(); - }; - return messages.stream() - .map(mapper) - .flatMap(List::stream) - .toArray(com.sap.ai.sdk.orchestration.Message[]::new); - } - - @Nonnull - private static MessageToolCall toOrchestrationToolCall(@Nonnull final ToolCall toolCall) { - return MessageToolCall.create() - .id(toolCall.id()) - .type(FUNCTION) - .function( - MessageToolCallFunction.create().name(toolCall.name()).arguments(toolCall.arguments())); + throw new IllegalArgumentException( + "Please add OrchestrationChatOptions to the Prompt: new Prompt(\"message\", new OrchestrationChatOptions(config))"); + } + + @Override + @Nonnull + public Flux stream(@Nonnull final Prompt prompt) { + + if (prompt.getOptions() instanceof OrchestrationChatOptions options) { + + val orchestrationPrompt = toOrchestrationPrompt(prompt); + val request = toCompletionPostRequest(orchestrationPrompt, options.getConfig()); + val stream = client.streamChatCompletionDeltas(request); + + final Flux flux = + Flux.generate( + stream::iterator, + (iterator, sink) -> { + if (iterator.hasNext()) { + sink.next(iterator.next()); + } else { + sink.complete(); + } + return iterator; + }); + return flux.map(OrchestrationSpringChatDelta::new); } + throw new IllegalArgumentException( + "Please add OrchestrationChatOptions to the Prompt: new Prompt(\"message\", new OrchestrationChatOptions(config))"); + } + + @Nonnull + private OrchestrationPrompt toOrchestrationPrompt(@Nonnull final Prompt prompt) { + val messages = toOrchestrationMessages(prompt.getInstructions()); + return new OrchestrationPrompt(Map.of(), messages); + } + + @Nonnull + private static com.sap.ai.sdk.orchestration.Message[] toOrchestrationMessages( + @Nonnull final List messages) { + final Function> mapper = + msg -> + switch (msg.getMessageType()) { + case SYSTEM: + yield List.of(new SystemMessage(msg.getText())); + case USER: + yield List.of(new UserMessage(msg.getText())); + case ASSISTANT: + val assistantMessage = new AssistantMessage(msg.getText()); + val springToolCalls = + ((org.springframework.ai.chat.messages.AssistantMessage) msg).getToolCalls(); + if (springToolCalls != null && !springToolCalls.isEmpty()) { + final List sdkToolCalls = + springToolCalls.stream() + .map(OrchestrationChatModel::toOrchestrationToolCall) + .toList(); + yield List.of(assistantMessage.withToolCalls(sdkToolCalls)); + } + yield List.of(assistantMessage); + case TOOL: + val toolResponses = ((ToolResponseMessage) msg).getResponses(); + yield toolResponses.stream() + .map( + r -> + (com.sap.ai.sdk.orchestration.Message) + new ToolMessage(r.id(), r.responseData())) + .toList(); + }; + return messages.stream() + .map(mapper) + .flatMap(List::stream) + .toArray(com.sap.ai.sdk.orchestration.Message[]::new); + } + + @Nonnull + private static MessageToolCall toOrchestrationToolCall(@Nonnull final ToolCall toolCall) { + return MessageToolCall.create() + .id(toolCall.id()) + .type(FUNCTION) + .function( + MessageToolCallFunction.create().name(toolCall.name()).arguments(toolCall.arguments())); + } } From 5665644782b6e26441892bd744aa06e61e872880 Mon Sep 17 00:00:00 2001 From: Nourhan Shata Date: Mon, 27 Oct 2025 13:55:13 +0100 Subject: [PATCH 11/26] Some Formatting --- .../openai/spring/OpenAiChatModel.java | 327 +++++++++--------- 1 file changed, 162 insertions(+), 165 deletions(-) diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/spring/OpenAiChatModel.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/spring/OpenAiChatModel.java index 22f59c8cf..d670b121e 100644 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/spring/OpenAiChatModel.java +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/spring/OpenAiChatModel.java @@ -16,14 +16,12 @@ import com.sap.ai.sdk.foundationmodels.openai.generated.model.CreateChatCompletionResponseChoicesInner; import com.sap.ai.sdk.foundationmodels.openai.generated.model.FunctionObject; import io.vavr.control.Option; - import java.math.BigDecimal; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.function.Function; import javax.annotation.Nonnull; - import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import lombok.val; @@ -48,180 +46,179 @@ @RequiredArgsConstructor public class OpenAiChatModel implements ChatModel { - private final OpenAiClient client; - - @Nonnull - private final DefaultToolCallingManager toolCallingManager = - DefaultToolCallingManager.builder().build(); - - @Override - @Nonnull - public ChatResponse call(@Nonnull final Prompt prompt) { - val options = prompt.getOptions(); - var request = new OpenAiChatCompletionRequest(extractMessages(prompt)); - - if (options != null) { - request = extractOptions(request, options); - } - if ((options instanceof ToolCallingChatOptions toolOptions)) { - request = request.withTools(extractTools(toolOptions)); - } - - val result = client.chatCompletion(request); - val response = new ChatResponse(toGenerations(result)); - - if (options != null && isInternalToolExecutionEnabled(options) && response.hasToolCalls()) { - val toolExecutionResult = toolCallingManager.executeToolCalls(prompt, response); - // Send the tool execution result back to the model. - log.info("Re-invoking model with tool execution results."); - return call(new Prompt(toolExecutionResult.conversationHistory(), options)); - } - return response; - } + private final OpenAiClient client; - @Override - @Nonnull - public Flux stream(@Nonnull final Prompt prompt) { - val options = prompt.getOptions(); - var request = new OpenAiChatCompletionRequest(extractMessages(prompt)); - - if (options != null) { - request = extractOptions(request, options); - } - if ((options instanceof ToolCallingChatOptions toolOptions)) { - request = request.withTools(extractTools(toolOptions)); - } - - val stream = client.streamChatCompletionDeltas(request); - final Flux flux = - Flux.generate( - stream::iterator, - (iterator, sink) -> { - if (iterator.hasNext()) { - sink.next(iterator.next()); - } else { - sink.complete(); - } - return iterator; - }); - return flux.map( - delta -> { - val assistantMessage = new AssistantMessage(delta.getDeltaContent(), Map.of()); - val metadata = - ChatGenerationMetadata.builder().finishReason(delta.getFinishReason()).build(); - return new ChatResponse(List.of(new Generation(assistantMessage, metadata))); - }); - } + @Nonnull + private final DefaultToolCallingManager toolCallingManager = + DefaultToolCallingManager.builder().build(); - private static List extractMessages(final Prompt prompt) { - final List result = new ArrayList<>(); - for (final Message message : prompt.getInstructions()) { - switch (message.getMessageType()) { - case USER -> Option.of(message.getText()).peek(t -> result.add(OpenAiMessage.user(t))); - case SYSTEM -> Option.of(message.getText()).peek(t -> result.add(OpenAiMessage.system(t))); - case ASSISTANT -> addAssistantMessage(result, (AssistantMessage) message); - case TOOL -> addToolMessages(result, (ToolResponseMessage) message); - } - } - return result; - } + @Override + @Nonnull + public ChatResponse call(@Nonnull final Prompt prompt) { + val options = prompt.getOptions(); + var request = new OpenAiChatCompletionRequest(extractMessages(prompt)); - private static void addAssistantMessage( - final List result, final AssistantMessage message) { - if (message.getText() != null) { - result.add(OpenAiMessage.assistant(message.getText())); - return; - } - final Function callTranslate = - toolCall -> OpenAiToolCall.function(toolCall.id(), toolCall.name(), toolCall.arguments()); - val calls = message.getToolCalls().stream().map(callTranslate).toList(); - result.add(OpenAiMessage.assistant(calls)); + if (options != null) { + request = extractOptions(request, options); } - - private static void addToolMessages( - final List result, final ToolResponseMessage message) { - for (final ToolResponseMessage.ToolResponse response : message.getResponses()) { - result.add(OpenAiMessage.tool(response.responseData(), response.id())); - } + if ((options instanceof ToolCallingChatOptions toolOptions)) { + request = request.withTools(extractTools(toolOptions)); } - @Nonnull - private static List toGenerations( - @Nonnull final OpenAiChatCompletionResponse result) { - return result.getOriginalResponse().getChoices().stream() - .map(OpenAiChatModel::toGeneration) - .toList(); + val result = client.chatCompletion(request); + val response = new ChatResponse(toGenerations(result)); + + if (options != null && isInternalToolExecutionEnabled(options) && response.hasToolCalls()) { + val toolExecutionResult = toolCallingManager.executeToolCalls(prompt, response); + // Send the tool execution result back to the model. + log.info("Re-invoking model with tool execution results."); + return call(new Prompt(toolExecutionResult.conversationHistory(), options)); } + return response; + } - @Nonnull - private static Generation toGeneration( - @Nonnull final CreateChatCompletionResponseChoicesInner choice) { - val metadata = - ChatGenerationMetadata.builder().finishReason(choice.getFinishReason().getValue()); - metadata.metadata("index", choice.getIndex()); - if (choice.getLogprobs() != null && !choice.getLogprobs().getContent().isEmpty()) { - metadata.metadata("logprobs", choice.getLogprobs().getContent()); - } - val message = choice.getMessage(); - val calls = new ArrayList(); - if (message.getToolCalls() != null) { - for (final ChatCompletionMessageToolCall c : message.getToolCalls()) { - val fnc = c.getFunction(); - calls.add( - new ToolCall(c.getId(), c.getType().getValue(), fnc.getName(), fnc.getArguments())); - } - } - - val assistantMessage = new AssistantMessage(message.getContent(), Map.of(), calls); - return new Generation(assistantMessage, metadata.build()); + @Override + @Nonnull + public Flux stream(@Nonnull final Prompt prompt) { + val options = prompt.getOptions(); + var request = new OpenAiChatCompletionRequest(extractMessages(prompt)); + + if (options != null) { + request = extractOptions(request, options); + } + if ((options instanceof ToolCallingChatOptions toolOptions)) { + request = request.withTools(extractTools(toolOptions)); } - /** - * Adds options to the request. - * - * @param request the request to modify - * @param options the options to extract - * @return the modified request with options applied - */ - @Nonnull - protected static OpenAiChatCompletionRequest extractOptions( - @Nonnull OpenAiChatCompletionRequest request, @Nonnull final ChatOptions options) { - request = request.withStop(options.getStopSequences()).withMaxTokens(options.getMaxTokens()); - if (options.getTemperature() != null) { - request = request.withTemperature(BigDecimal.valueOf(options.getTemperature())); - } - if (options.getTopP() != null) { - request = request.withTopP(BigDecimal.valueOf(options.getTopP())); - } - if (options.getPresencePenalty() != null) { - request = request.withPresencePenalty(BigDecimal.valueOf(options.getPresencePenalty())); - } - if (options.getFrequencyPenalty() != null) { - request = request.withFrequencyPenalty(BigDecimal.valueOf(options.getFrequencyPenalty())); - } - return request; + val stream = client.streamChatCompletionDeltas(request); + final Flux flux = + Flux.generate( + stream::iterator, + (iterator, sink) -> { + if (iterator.hasNext()) { + sink.next(iterator.next()); + } else { + sink.complete(); + } + return iterator; + }); + return flux.map( + delta -> { + val assistantMessage = new AssistantMessage(delta.getDeltaContent(), Map.of()); + val metadata = + ChatGenerationMetadata.builder().finishReason(delta.getFinishReason()).build(); + return new ChatResponse(List.of(new Generation(assistantMessage, metadata))); + }); + } + + private static List extractMessages(final Prompt prompt) { + final List result = new ArrayList<>(); + for (final Message message : prompt.getInstructions()) { + switch (message.getMessageType()) { + case USER -> Option.of(message.getText()).peek(t -> result.add(OpenAiMessage.user(t))); + case SYSTEM -> Option.of(message.getText()).peek(t -> result.add(OpenAiMessage.system(t))); + case ASSISTANT -> addAssistantMessage(result, (AssistantMessage) message); + case TOOL -> addToolMessages(result, (ToolResponseMessage) message); + } + } + return result; + } + + private static void addAssistantMessage( + final List result, final AssistantMessage message) { + if (message.getText() != null) { + result.add(OpenAiMessage.assistant(message.getText())); + return; + } + final Function callTranslate = + toolCall -> OpenAiToolCall.function(toolCall.id(), toolCall.name(), toolCall.arguments()); + val calls = message.getToolCalls().stream().map(callTranslate).toList(); + result.add(OpenAiMessage.assistant(calls)); + } + + private static void addToolMessages( + final List result, final ToolResponseMessage message) { + for (final ToolResponseMessage.ToolResponse response : message.getResponses()) { + result.add(OpenAiMessage.tool(response.responseData(), response.id())); + } + } + + @Nonnull + private static List toGenerations( + @Nonnull final OpenAiChatCompletionResponse result) { + return result.getOriginalResponse().getChoices().stream() + .map(OpenAiChatModel::toGeneration) + .toList(); + } + + @Nonnull + private static Generation toGeneration( + @Nonnull final CreateChatCompletionResponseChoicesInner choice) { + val metadata = + ChatGenerationMetadata.builder().finishReason(choice.getFinishReason().getValue()); + metadata.metadata("index", choice.getIndex()); + if (choice.getLogprobs() != null && !choice.getLogprobs().getContent().isEmpty()) { + metadata.metadata("logprobs", choice.getLogprobs().getContent()); + } + val message = choice.getMessage(); + val calls = new ArrayList(); + if (message.getToolCalls() != null) { + for (final ChatCompletionMessageToolCall c : message.getToolCalls()) { + val fnc = c.getFunction(); + calls.add( + new ToolCall(c.getId(), c.getType().getValue(), fnc.getName(), fnc.getArguments())); + } } - private static List extractTools(final ToolCallingChatOptions options) { - val tools = new ArrayList(); - for (val toolCallback : options.getToolCallbacks()) { - val toolDefinition = toolCallback.getToolDefinition(); - try { - final Map params = - new ObjectMapper().readValue(toolDefinition.inputSchema(), new TypeReference<>() { - }); - val toolType = ChatCompletionTool.TypeEnum.FUNCTION; - val toolFunction = - new FunctionObject() - .name(toolDefinition.name()) - .description(toolDefinition.description()) - .parameters(params); - val tool = new ChatCompletionTool().type(toolType).function(toolFunction); - tools.add(tool); - } catch (JsonProcessingException e) { - log.warn("Failed to add tool to the chat request: {}", e.getMessage()); - } - } - return tools; + val assistantMessage = new AssistantMessage(message.getContent(), Map.of(), calls); + return new Generation(assistantMessage, metadata.build()); + } + + /** + * Adds options to the request. + * + * @param request the request to modify + * @param options the options to extract + * @return the modified request with options applied + */ + @Nonnull + protected static OpenAiChatCompletionRequest extractOptions( + @Nonnull OpenAiChatCompletionRequest request, @Nonnull final ChatOptions options) { + request = request.withStop(options.getStopSequences()).withMaxTokens(options.getMaxTokens()); + if (options.getTemperature() != null) { + request = request.withTemperature(BigDecimal.valueOf(options.getTemperature())); + } + if (options.getTopP() != null) { + request = request.withTopP(BigDecimal.valueOf(options.getTopP())); + } + if (options.getPresencePenalty() != null) { + request = request.withPresencePenalty(BigDecimal.valueOf(options.getPresencePenalty())); + } + if (options.getFrequencyPenalty() != null) { + request = request.withFrequencyPenalty(BigDecimal.valueOf(options.getFrequencyPenalty())); + } + return request; + } + + private static List extractTools(final ToolCallingChatOptions options) { + val tools = new ArrayList(); + for (val toolCallback : options.getToolCallbacks()) { + val toolDefinition = toolCallback.getToolDefinition(); + try { + final Map params = + new ObjectMapper().readValue(toolDefinition.inputSchema(), new TypeReference<>() {}); + val toolType = ChatCompletionTool.TypeEnum.FUNCTION; + val toolFunction = + new FunctionObject() + .name(toolDefinition.name()) + .description(toolDefinition.description()) + .parameters(params); + val tool = new ChatCompletionTool().type(toolType).function(toolFunction); + tools.add(tool); + } catch (JsonProcessingException e) { + log.warn("Failed to add tool to the chat request: {}", e.getMessage()); + } } + return tools; + } } From 51195fa96dcc6c70cd59c973fbca86a2ab7c0ba8 Mon Sep 17 00:00:00 2001 From: I538344 Date: Mon, 27 Oct 2025 13:55:13 +0100 Subject: [PATCH 12/26] formatting --- .../core/common/ClientResponseHandler.java | 278 +++++++-------- .../openai/spring/OpenAiChatModel.java | 327 +++++++++--------- .../spring/OrchestrationChatModel.java | 251 +++++++------- .../spring/OrchestrationChatModelTest.java | 2 +- 4 files changed, 419 insertions(+), 439 deletions(-) diff --git a/core/src/main/java/com/sap/ai/sdk/core/common/ClientResponseHandler.java b/core/src/main/java/com/sap/ai/sdk/core/common/ClientResponseHandler.java index 6d6179073..b627e0223 100644 --- a/core/src/main/java/com/sap/ai/sdk/core/common/ClientResponseHandler.java +++ b/core/src/main/java/com/sap/ai/sdk/core/common/ClientResponseHandler.java @@ -6,12 +6,10 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.google.common.annotations.Beta; import io.vavr.control.Try; - import java.nio.charset.StandardCharsets; import java.util.Optional; import javax.annotation.Nonnull; import javax.annotation.Nullable; - import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import lombok.val; @@ -33,158 +31,146 @@ @Slf4j @RequiredArgsConstructor public class ClientResponseHandler - implements HttpClientResponseHandler { - /** - * The HTTP success response type - */ - @Nonnull - final Class successType; - - /** - * The HTTP error response type - */ - @Nonnull - final Class errorType; - - /** - * The factory to create exceptions for Http 4xx/5xx responses. - */ - @Nonnull - final ClientExceptionFactory exceptionFactory; - - /** - * The parses for JSON responses, will be private once we can remove mixins - */ - @Nonnull - ObjectMapper objectMapper = getDefaultObjectMapper(); - - /** - * Set the {@link ObjectMapper} to use for parsing JSON responses. - * - * @param jackson The {@link ObjectMapper} to use - * @return the current instance of {@link ClientResponseHandler} with the changed object mapper - */ - @Beta - @Nonnull - public ClientResponseHandler objectMapper(@Nonnull final ObjectMapper jackson) { - objectMapper = jackson; - return this; + implements HttpClientResponseHandler { + /** The HTTP success response type */ + @Nonnull final Class successType; + + /** The HTTP error response type */ + @Nonnull final Class errorType; + + /** The factory to create exceptions for Http 4xx/5xx responses. */ + @Nonnull final ClientExceptionFactory exceptionFactory; + + /** The parses for JSON responses, will be private once we can remove mixins */ + @Nonnull ObjectMapper objectMapper = getDefaultObjectMapper(); + + /** + * Set the {@link ObjectMapper} to use for parsing JSON responses. + * + * @param jackson The {@link ObjectMapper} to use + * @return the current instance of {@link ClientResponseHandler} with the changed object mapper + */ + @Beta + @Nonnull + public ClientResponseHandler objectMapper(@Nonnull final ObjectMapper jackson) { + objectMapper = jackson; + return this; + } + + /** + * Processes a {@link ClassicHttpResponse} and returns some value corresponding to that response. + * + * @param response The response to process + * @return A model class instantiated from the response + * @throws E in case of a problem or the connection was aborted + */ + @Nonnull + @Override + public T handleResponse(@Nonnull final ClassicHttpResponse response) throws E { + if (response.getCode() >= 300) { + buildAndThrowException(response); } - - /** - * Processes a {@link ClassicHttpResponse} and returns some value corresponding to that response. - * - * @param response The response to process - * @return A model class instantiated from the response - * @throws E in case of a problem or the connection was aborted - */ - @Nonnull - @Override - public T handleResponse(@Nonnull final ClassicHttpResponse response) throws E { - if (response.getCode() >= 300) { - buildAndThrowException(response); - } - return parseSuccess(response); + return parseSuccess(response); + } + + // The InputStream of the HTTP entity is closed by EntityUtils.toString + @SuppressWarnings("PMD.CloseResource") + @Nonnull + private T parseSuccess(@Nonnull final ClassicHttpResponse response) throws E { + final HttpEntity responseEntity = response.getEntity(); + if (responseEntity == null) { + throw exceptionFactory.build("The HTTP Response is empty").setHttpResponse(response); } - // The InputStream of the HTTP entity is closed by EntityUtils.toString - @SuppressWarnings("PMD.CloseResource") - @Nonnull - private T parseSuccess(@Nonnull final ClassicHttpResponse response) throws E { - final HttpEntity responseEntity = response.getEntity(); - if (responseEntity == null) { - throw exceptionFactory.build("The HTTP Response is empty").setHttpResponse(response); - } - - val message = "Failed to parse response entity."; - val content = - tryGetContent(responseEntity) - .getOrElseThrow(e -> exceptionFactory.build(message, e).setHttpResponse(response)); - try { - final T value = objectMapper.readValue(content, successType); - log.info( - "LLM request success with duration:{}", - response.getHeaders("x-upstream-service-time")[0].getValue()); - return value; - } catch (final JsonProcessingException e) { - log.error("Failed to parse response to type {}", successType); - throw exceptionFactory.build("Failed to parse response", e).setHttpResponse(response); - } + val message = "Failed to parse response entity."; + val content = + tryGetContent(responseEntity) + .getOrElseThrow(e -> exceptionFactory.build(message, e).setHttpResponse(response)); + try { + final T value = objectMapper.readValue(content, successType); + log.info( + "LLM request success with duration:{}", + response.getHeaders("x-upstream-service-time")[0].getValue()); + return value; + } catch (final JsonProcessingException e) { + log.error("Failed to parse response to type {}", successType); + throw exceptionFactory.build("Failed to parse response", e).setHttpResponse(response); } - - @Nonnull - private Try tryGetContent(@Nonnull final HttpEntity entity) { - return Try.of(() -> EntityUtils.toString(entity, StandardCharsets.UTF_8)); + } + + @Nonnull + private Try tryGetContent(@Nonnull final HttpEntity entity) { + return Try.of(() -> EntityUtils.toString(entity, StandardCharsets.UTF_8)); + } + + /** + * Process the error response and throw an exception. + * + * @param httpResponse The response to process + * @throws ClientException if the response is an error (4xx/5xx) + */ + @SuppressWarnings("PMD.CloseResource") + protected void buildAndThrowException(@Nonnull final ClassicHttpResponse httpResponse) throws E { + + val entity = httpResponse.getEntity(); + + if (entity == null) { + val message = getErrorMessage(httpResponse, "The HTTP Response is empty"); + throw exceptionFactory.build(message).setHttpResponse(httpResponse); } - - /** - * Process the error response and throw an exception. - * - * @param httpResponse The response to process - * @throws ClientException if the response is an error (4xx/5xx) - */ - @SuppressWarnings("PMD.CloseResource") - protected void buildAndThrowException(@Nonnull final ClassicHttpResponse httpResponse) throws E { - - val entity = httpResponse.getEntity(); - - if (entity == null) { - val message = getErrorMessage(httpResponse, "The HTTP Response is empty"); - throw exceptionFactory.build(message).setHttpResponse(httpResponse); - } - val maybeContent = tryGetContent(entity); - if (maybeContent.isFailure()) { - val message = getErrorMessage(httpResponse, "Failed to read the response content"); - val baseException = exceptionFactory.build(message).setHttpResponse(httpResponse); - baseException.addSuppressed(maybeContent.getCause()); - throw baseException; - } - val content = maybeContent.get(); - if (content == null || content.isBlank()) { - val message = getErrorMessage(httpResponse, "Empty or blank response content"); - throw exceptionFactory.build(message).setHttpResponse(httpResponse); - } - - log.error( - "The service responded with an HTTP {} ({})", - httpResponse.getCode(), - httpResponse.getReasonPhrase()); - val contentType = ContentType.parse(entity.getContentType()); - if (!ContentType.APPLICATION_JSON.isSameMimeType(contentType)) { - val message = getErrorMessage(httpResponse, "The response Content-Type is not JSON"); - throw exceptionFactory.build(message).setHttpResponse(httpResponse); - } - - parseErrorResponseAndThrow(content, httpResponse); + val maybeContent = tryGetContent(entity); + if (maybeContent.isFailure()) { + val message = getErrorMessage(httpResponse, "Failed to read the response content"); + val baseException = exceptionFactory.build(message).setHttpResponse(httpResponse); + baseException.addSuppressed(maybeContent.getCause()); + throw baseException; } - - /** - * Parses the JSON content of an error response and throws a module specific exception. - * - * @param content The JSON content of the error response. - * @param httpResponse The HTTP response that contains the error. - * @throws ClientException if the response is an error (4xx/5xx) - */ - protected void parseErrorResponseAndThrow( - @Nonnull final String content, @Nonnull final ClassicHttpResponse httpResponse) throws E { - val maybeClientError = Try.of(() -> objectMapper.readValue(content, errorType)); - if (maybeClientError.isFailure()) { - val message = getErrorMessage(httpResponse, "Failed to parse the JSON error response"); - val baseException = exceptionFactory.build(message).setHttpResponse(httpResponse); - baseException.addSuppressed(maybeClientError.getCause()); - throw baseException; - } - final R clientError = maybeClientError.get(); - val message = getErrorMessage(httpResponse, clientError.getMessage()); - throw exceptionFactory.build(message, clientError, null).setHttpResponse(httpResponse); + val content = maybeContent.get(); + if (content == null || content.isBlank()) { + val message = getErrorMessage(httpResponse, "Empty or blank response content"); + throw exceptionFactory.build(message).setHttpResponse(httpResponse); } - private static String getErrorMessage( - @Nonnull final ClassicHttpResponse rsp, @Nullable final String additionalMessage) { - val baseErrorMessage = - "Request failed with status %d (%s)".formatted(rsp.getCode(), rsp.getReasonPhrase()); + log.error( + "The service responded with an HTTP {} ({})", + httpResponse.getCode(), + httpResponse.getReasonPhrase()); + val contentType = ContentType.parse(entity.getContentType()); + if (!ContentType.APPLICATION_JSON.isSameMimeType(contentType)) { + val message = getErrorMessage(httpResponse, "The response Content-Type is not JSON"); + throw exceptionFactory.build(message).setHttpResponse(httpResponse); + } - val message = Optional.ofNullable(additionalMessage).orElse(""); - return message.isEmpty() ? baseErrorMessage : "%s: %s".formatted(baseErrorMessage, message); + parseErrorResponseAndThrow(content, httpResponse); + } + + /** + * Parses the JSON content of an error response and throws a module specific exception. + * + * @param content The JSON content of the error response. + * @param httpResponse The HTTP response that contains the error. + * @throws ClientException if the response is an error (4xx/5xx) + */ + protected void parseErrorResponseAndThrow( + @Nonnull final String content, @Nonnull final ClassicHttpResponse httpResponse) throws E { + val maybeClientError = Try.of(() -> objectMapper.readValue(content, errorType)); + if (maybeClientError.isFailure()) { + val message = getErrorMessage(httpResponse, "Failed to parse the JSON error response"); + val baseException = exceptionFactory.build(message).setHttpResponse(httpResponse); + baseException.addSuppressed(maybeClientError.getCause()); + throw baseException; } + final R clientError = maybeClientError.get(); + val message = getErrorMessage(httpResponse, clientError.getMessage()); + throw exceptionFactory.build(message, clientError, null).setHttpResponse(httpResponse); + } + + private static String getErrorMessage( + @Nonnull final ClassicHttpResponse rsp, @Nullable final String additionalMessage) { + val baseErrorMessage = + "Request failed with status %d (%s)".formatted(rsp.getCode(), rsp.getReasonPhrase()); + + val message = Optional.ofNullable(additionalMessage).orElse(""); + return message.isEmpty() ? baseErrorMessage : "%s: %s".formatted(baseErrorMessage, message); + } } diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/spring/OpenAiChatModel.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/spring/OpenAiChatModel.java index 22f59c8cf..d670b121e 100644 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/spring/OpenAiChatModel.java +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/spring/OpenAiChatModel.java @@ -16,14 +16,12 @@ import com.sap.ai.sdk.foundationmodels.openai.generated.model.CreateChatCompletionResponseChoicesInner; import com.sap.ai.sdk.foundationmodels.openai.generated.model.FunctionObject; import io.vavr.control.Option; - import java.math.BigDecimal; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.function.Function; import javax.annotation.Nonnull; - import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import lombok.val; @@ -48,180 +46,179 @@ @RequiredArgsConstructor public class OpenAiChatModel implements ChatModel { - private final OpenAiClient client; - - @Nonnull - private final DefaultToolCallingManager toolCallingManager = - DefaultToolCallingManager.builder().build(); - - @Override - @Nonnull - public ChatResponse call(@Nonnull final Prompt prompt) { - val options = prompt.getOptions(); - var request = new OpenAiChatCompletionRequest(extractMessages(prompt)); - - if (options != null) { - request = extractOptions(request, options); - } - if ((options instanceof ToolCallingChatOptions toolOptions)) { - request = request.withTools(extractTools(toolOptions)); - } - - val result = client.chatCompletion(request); - val response = new ChatResponse(toGenerations(result)); - - if (options != null && isInternalToolExecutionEnabled(options) && response.hasToolCalls()) { - val toolExecutionResult = toolCallingManager.executeToolCalls(prompt, response); - // Send the tool execution result back to the model. - log.info("Re-invoking model with tool execution results."); - return call(new Prompt(toolExecutionResult.conversationHistory(), options)); - } - return response; - } + private final OpenAiClient client; - @Override - @Nonnull - public Flux stream(@Nonnull final Prompt prompt) { - val options = prompt.getOptions(); - var request = new OpenAiChatCompletionRequest(extractMessages(prompt)); - - if (options != null) { - request = extractOptions(request, options); - } - if ((options instanceof ToolCallingChatOptions toolOptions)) { - request = request.withTools(extractTools(toolOptions)); - } - - val stream = client.streamChatCompletionDeltas(request); - final Flux flux = - Flux.generate( - stream::iterator, - (iterator, sink) -> { - if (iterator.hasNext()) { - sink.next(iterator.next()); - } else { - sink.complete(); - } - return iterator; - }); - return flux.map( - delta -> { - val assistantMessage = new AssistantMessage(delta.getDeltaContent(), Map.of()); - val metadata = - ChatGenerationMetadata.builder().finishReason(delta.getFinishReason()).build(); - return new ChatResponse(List.of(new Generation(assistantMessage, metadata))); - }); - } + @Nonnull + private final DefaultToolCallingManager toolCallingManager = + DefaultToolCallingManager.builder().build(); - private static List extractMessages(final Prompt prompt) { - final List result = new ArrayList<>(); - for (final Message message : prompt.getInstructions()) { - switch (message.getMessageType()) { - case USER -> Option.of(message.getText()).peek(t -> result.add(OpenAiMessage.user(t))); - case SYSTEM -> Option.of(message.getText()).peek(t -> result.add(OpenAiMessage.system(t))); - case ASSISTANT -> addAssistantMessage(result, (AssistantMessage) message); - case TOOL -> addToolMessages(result, (ToolResponseMessage) message); - } - } - return result; - } + @Override + @Nonnull + public ChatResponse call(@Nonnull final Prompt prompt) { + val options = prompt.getOptions(); + var request = new OpenAiChatCompletionRequest(extractMessages(prompt)); - private static void addAssistantMessage( - final List result, final AssistantMessage message) { - if (message.getText() != null) { - result.add(OpenAiMessage.assistant(message.getText())); - return; - } - final Function callTranslate = - toolCall -> OpenAiToolCall.function(toolCall.id(), toolCall.name(), toolCall.arguments()); - val calls = message.getToolCalls().stream().map(callTranslate).toList(); - result.add(OpenAiMessage.assistant(calls)); + if (options != null) { + request = extractOptions(request, options); } - - private static void addToolMessages( - final List result, final ToolResponseMessage message) { - for (final ToolResponseMessage.ToolResponse response : message.getResponses()) { - result.add(OpenAiMessage.tool(response.responseData(), response.id())); - } + if ((options instanceof ToolCallingChatOptions toolOptions)) { + request = request.withTools(extractTools(toolOptions)); } - @Nonnull - private static List toGenerations( - @Nonnull final OpenAiChatCompletionResponse result) { - return result.getOriginalResponse().getChoices().stream() - .map(OpenAiChatModel::toGeneration) - .toList(); + val result = client.chatCompletion(request); + val response = new ChatResponse(toGenerations(result)); + + if (options != null && isInternalToolExecutionEnabled(options) && response.hasToolCalls()) { + val toolExecutionResult = toolCallingManager.executeToolCalls(prompt, response); + // Send the tool execution result back to the model. + log.info("Re-invoking model with tool execution results."); + return call(new Prompt(toolExecutionResult.conversationHistory(), options)); } + return response; + } - @Nonnull - private static Generation toGeneration( - @Nonnull final CreateChatCompletionResponseChoicesInner choice) { - val metadata = - ChatGenerationMetadata.builder().finishReason(choice.getFinishReason().getValue()); - metadata.metadata("index", choice.getIndex()); - if (choice.getLogprobs() != null && !choice.getLogprobs().getContent().isEmpty()) { - metadata.metadata("logprobs", choice.getLogprobs().getContent()); - } - val message = choice.getMessage(); - val calls = new ArrayList(); - if (message.getToolCalls() != null) { - for (final ChatCompletionMessageToolCall c : message.getToolCalls()) { - val fnc = c.getFunction(); - calls.add( - new ToolCall(c.getId(), c.getType().getValue(), fnc.getName(), fnc.getArguments())); - } - } - - val assistantMessage = new AssistantMessage(message.getContent(), Map.of(), calls); - return new Generation(assistantMessage, metadata.build()); + @Override + @Nonnull + public Flux stream(@Nonnull final Prompt prompt) { + val options = prompt.getOptions(); + var request = new OpenAiChatCompletionRequest(extractMessages(prompt)); + + if (options != null) { + request = extractOptions(request, options); + } + if ((options instanceof ToolCallingChatOptions toolOptions)) { + request = request.withTools(extractTools(toolOptions)); } - /** - * Adds options to the request. - * - * @param request the request to modify - * @param options the options to extract - * @return the modified request with options applied - */ - @Nonnull - protected static OpenAiChatCompletionRequest extractOptions( - @Nonnull OpenAiChatCompletionRequest request, @Nonnull final ChatOptions options) { - request = request.withStop(options.getStopSequences()).withMaxTokens(options.getMaxTokens()); - if (options.getTemperature() != null) { - request = request.withTemperature(BigDecimal.valueOf(options.getTemperature())); - } - if (options.getTopP() != null) { - request = request.withTopP(BigDecimal.valueOf(options.getTopP())); - } - if (options.getPresencePenalty() != null) { - request = request.withPresencePenalty(BigDecimal.valueOf(options.getPresencePenalty())); - } - if (options.getFrequencyPenalty() != null) { - request = request.withFrequencyPenalty(BigDecimal.valueOf(options.getFrequencyPenalty())); - } - return request; + val stream = client.streamChatCompletionDeltas(request); + final Flux flux = + Flux.generate( + stream::iterator, + (iterator, sink) -> { + if (iterator.hasNext()) { + sink.next(iterator.next()); + } else { + sink.complete(); + } + return iterator; + }); + return flux.map( + delta -> { + val assistantMessage = new AssistantMessage(delta.getDeltaContent(), Map.of()); + val metadata = + ChatGenerationMetadata.builder().finishReason(delta.getFinishReason()).build(); + return new ChatResponse(List.of(new Generation(assistantMessage, metadata))); + }); + } + + private static List extractMessages(final Prompt prompt) { + final List result = new ArrayList<>(); + for (final Message message : prompt.getInstructions()) { + switch (message.getMessageType()) { + case USER -> Option.of(message.getText()).peek(t -> result.add(OpenAiMessage.user(t))); + case SYSTEM -> Option.of(message.getText()).peek(t -> result.add(OpenAiMessage.system(t))); + case ASSISTANT -> addAssistantMessage(result, (AssistantMessage) message); + case TOOL -> addToolMessages(result, (ToolResponseMessage) message); + } + } + return result; + } + + private static void addAssistantMessage( + final List result, final AssistantMessage message) { + if (message.getText() != null) { + result.add(OpenAiMessage.assistant(message.getText())); + return; + } + final Function callTranslate = + toolCall -> OpenAiToolCall.function(toolCall.id(), toolCall.name(), toolCall.arguments()); + val calls = message.getToolCalls().stream().map(callTranslate).toList(); + result.add(OpenAiMessage.assistant(calls)); + } + + private static void addToolMessages( + final List result, final ToolResponseMessage message) { + for (final ToolResponseMessage.ToolResponse response : message.getResponses()) { + result.add(OpenAiMessage.tool(response.responseData(), response.id())); + } + } + + @Nonnull + private static List toGenerations( + @Nonnull final OpenAiChatCompletionResponse result) { + return result.getOriginalResponse().getChoices().stream() + .map(OpenAiChatModel::toGeneration) + .toList(); + } + + @Nonnull + private static Generation toGeneration( + @Nonnull final CreateChatCompletionResponseChoicesInner choice) { + val metadata = + ChatGenerationMetadata.builder().finishReason(choice.getFinishReason().getValue()); + metadata.metadata("index", choice.getIndex()); + if (choice.getLogprobs() != null && !choice.getLogprobs().getContent().isEmpty()) { + metadata.metadata("logprobs", choice.getLogprobs().getContent()); + } + val message = choice.getMessage(); + val calls = new ArrayList(); + if (message.getToolCalls() != null) { + for (final ChatCompletionMessageToolCall c : message.getToolCalls()) { + val fnc = c.getFunction(); + calls.add( + new ToolCall(c.getId(), c.getType().getValue(), fnc.getName(), fnc.getArguments())); + } } - private static List extractTools(final ToolCallingChatOptions options) { - val tools = new ArrayList(); - for (val toolCallback : options.getToolCallbacks()) { - val toolDefinition = toolCallback.getToolDefinition(); - try { - final Map params = - new ObjectMapper().readValue(toolDefinition.inputSchema(), new TypeReference<>() { - }); - val toolType = ChatCompletionTool.TypeEnum.FUNCTION; - val toolFunction = - new FunctionObject() - .name(toolDefinition.name()) - .description(toolDefinition.description()) - .parameters(params); - val tool = new ChatCompletionTool().type(toolType).function(toolFunction); - tools.add(tool); - } catch (JsonProcessingException e) { - log.warn("Failed to add tool to the chat request: {}", e.getMessage()); - } - } - return tools; + val assistantMessage = new AssistantMessage(message.getContent(), Map.of(), calls); + return new Generation(assistantMessage, metadata.build()); + } + + /** + * Adds options to the request. + * + * @param request the request to modify + * @param options the options to extract + * @return the modified request with options applied + */ + @Nonnull + protected static OpenAiChatCompletionRequest extractOptions( + @Nonnull OpenAiChatCompletionRequest request, @Nonnull final ChatOptions options) { + request = request.withStop(options.getStopSequences()).withMaxTokens(options.getMaxTokens()); + if (options.getTemperature() != null) { + request = request.withTemperature(BigDecimal.valueOf(options.getTemperature())); + } + if (options.getTopP() != null) { + request = request.withTopP(BigDecimal.valueOf(options.getTopP())); + } + if (options.getPresencePenalty() != null) { + request = request.withPresencePenalty(BigDecimal.valueOf(options.getPresencePenalty())); + } + if (options.getFrequencyPenalty() != null) { + request = request.withFrequencyPenalty(BigDecimal.valueOf(options.getFrequencyPenalty())); + } + return request; + } + + private static List extractTools(final ToolCallingChatOptions options) { + val tools = new ArrayList(); + for (val toolCallback : options.getToolCallbacks()) { + val toolDefinition = toolCallback.getToolDefinition(); + try { + final Map params = + new ObjectMapper().readValue(toolDefinition.inputSchema(), new TypeReference<>() {}); + val toolType = ChatCompletionTool.TypeEnum.FUNCTION; + val toolFunction = + new FunctionObject() + .name(toolDefinition.name()) + .description(toolDefinition.description()) + .parameters(params); + val tool = new ChatCompletionTool().type(toolType).function(toolFunction); + tools.add(tool); + } catch (JsonProcessingException e) { + log.warn("Failed to add tool to the chat request: {}", e.getMessage()); + } } + return tools; + } } diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/spring/OrchestrationChatModel.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/spring/OrchestrationChatModel.java index eb4750324..15d7667a8 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/spring/OrchestrationChatModel.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/spring/OrchestrationChatModel.java @@ -12,12 +12,10 @@ import com.sap.ai.sdk.orchestration.UserMessage; import com.sap.ai.sdk.orchestration.model.MessageToolCall; import com.sap.ai.sdk.orchestration.model.MessageToolCallFunction; - import java.util.List; import java.util.Map; import java.util.function.Function; import javax.annotation.Nonnull; - import lombok.extern.slf4j.Slf4j; import lombok.val; import org.springframework.ai.chat.messages.AssistantMessage.ToolCall; @@ -37,131 +35,130 @@ */ @Slf4j public class OrchestrationChatModel implements ChatModel { - @Nonnull - private final OrchestrationClient client; - - @Nonnull - private final DefaultToolCallingManager toolCallingManager = - DefaultToolCallingManager.builder().build(); - - /** - * Default constructor. - * - * @since 1.2.0 - */ - public OrchestrationChatModel() { - this(new OrchestrationClient()); - } - - /** - * Constructor with a custom client. - * - * @param client The custom client to use. - * @since 1.2.0 - */ - public OrchestrationChatModel(@Nonnull final OrchestrationClient client) { - this.client = client; + @Nonnull private final OrchestrationClient client; + + @Nonnull + private final DefaultToolCallingManager toolCallingManager = + DefaultToolCallingManager.builder().build(); + + /** + * Default constructor. + * + * @since 1.2.0 + */ + public OrchestrationChatModel() { + this(new OrchestrationClient()); + } + + /** + * Constructor with a custom client. + * + * @param client The custom client to use. + * @since 1.2.0 + */ + public OrchestrationChatModel(@Nonnull final OrchestrationClient client) { + this.client = client; + } + + @Nonnull + @Override + public ChatResponse call(@Nonnull final Prompt prompt) { + if (prompt.getOptions() instanceof OrchestrationChatOptions options) { + + val orchestrationPrompt = toOrchestrationPrompt(prompt); + val response = + new OrchestrationSpringChatResponse( + client.chatCompletion(orchestrationPrompt, options.getConfig())); + + if (ToolCallingChatOptions.isInternalToolExecutionEnabled(prompt.getOptions()) + && response.hasToolCalls()) { + val toolExecutionResult = toolCallingManager.executeToolCalls(prompt, response); + // Send the tool execution result back to the model. + log.info("Re-invoking model with tool execution results."); + return call(new Prompt(toolExecutionResult.conversationHistory(), prompt.getOptions())); + } + return response; } - - @Nonnull - @Override - public ChatResponse call(@Nonnull final Prompt prompt) { - if (prompt.getOptions() instanceof OrchestrationChatOptions options) { - - val orchestrationPrompt = toOrchestrationPrompt(prompt); - val response = - new OrchestrationSpringChatResponse( - client.chatCompletion(orchestrationPrompt, options.getConfig())); - - if (ToolCallingChatOptions.isInternalToolExecutionEnabled(prompt.getOptions()) - && response.hasToolCalls()) { - val toolExecutionResult = toolCallingManager.executeToolCalls(prompt, response); - // Send the tool execution result back to the model. - log.info("Re-invoking model with tool execution results."); - return call(new Prompt(toolExecutionResult.conversationHistory(), prompt.getOptions())); - } - return response; - } - throw new IllegalArgumentException( - "Please add OrchestrationChatOptions to the Prompt: new Prompt(\"message\", new OrchestrationChatOptions(config))"); - } - - @Override - @Nonnull - public Flux stream(@Nonnull final Prompt prompt) { - - if (prompt.getOptions() instanceof OrchestrationChatOptions options) { - - val orchestrationPrompt = toOrchestrationPrompt(prompt); - val request = toCompletionPostRequest(orchestrationPrompt, options.getConfig()); - val stream = client.streamChatCompletionDeltas(request); - - final Flux flux = - Flux.generate( - stream::iterator, - (iterator, sink) -> { - if (iterator.hasNext()) { - sink.next(iterator.next()); - } else { - sink.complete(); - } - return iterator; - }); - return flux.map(OrchestrationSpringChatDelta::new); - } - throw new IllegalArgumentException( - "Please add OrchestrationChatOptions to the Prompt: new Prompt(\"message\", new OrchestrationChatOptions(config))"); - } - - @Nonnull - private OrchestrationPrompt toOrchestrationPrompt(@Nonnull final Prompt prompt) { - val messages = toOrchestrationMessages(prompt.getInstructions()); - return new OrchestrationPrompt(Map.of(), messages); - } - - @Nonnull - private static com.sap.ai.sdk.orchestration.Message[] toOrchestrationMessages( - @Nonnull final List messages) { - final Function> mapper = - msg -> - switch (msg.getMessageType()) { - case SYSTEM: - yield List.of(new SystemMessage(msg.getText())); - case USER: - yield List.of(new UserMessage(msg.getText())); - case ASSISTANT: - val assistantMessage = new AssistantMessage(msg.getText()); - val springToolCalls = - ((org.springframework.ai.chat.messages.AssistantMessage) msg).getToolCalls(); - if (springToolCalls != null && !springToolCalls.isEmpty()) { - final List sdkToolCalls = - springToolCalls.stream() - .map(OrchestrationChatModel::toOrchestrationToolCall) - .toList(); - yield List.of(assistantMessage.withToolCalls(sdkToolCalls)); - } - yield List.of(assistantMessage); - case TOOL: - val toolResponses = ((ToolResponseMessage) msg).getResponses(); - yield toolResponses.stream() - .map( - r -> - (com.sap.ai.sdk.orchestration.Message) - new ToolMessage(r.id(), r.responseData())) - .toList(); - }; - return messages.stream() - .map(mapper) - .flatMap(List::stream) - .toArray(com.sap.ai.sdk.orchestration.Message[]::new); - } - - @Nonnull - private static MessageToolCall toOrchestrationToolCall(@Nonnull final ToolCall toolCall) { - return MessageToolCall.create() - .id(toolCall.id()) - .type(FUNCTION) - .function( - MessageToolCallFunction.create().name(toolCall.name()).arguments(toolCall.arguments())); + throw new IllegalArgumentException( + "Please add OrchestrationChatOptions to the Prompt: new Prompt(\"message\", new OrchestrationChatOptions(config))"); + } + + @Override + @Nonnull + public Flux stream(@Nonnull final Prompt prompt) { + + if (prompt.getOptions() instanceof OrchestrationChatOptions options) { + + val orchestrationPrompt = toOrchestrationPrompt(prompt); + val request = toCompletionPostRequest(orchestrationPrompt, options.getConfig()); + val stream = client.streamChatCompletionDeltas(request); + + final Flux flux = + Flux.generate( + stream::iterator, + (iterator, sink) -> { + if (iterator.hasNext()) { + sink.next(iterator.next()); + } else { + sink.complete(); + } + return iterator; + }); + return flux.map(OrchestrationSpringChatDelta::new); } + throw new IllegalArgumentException( + "Please add OrchestrationChatOptions to the Prompt: new Prompt(\"message\", new OrchestrationChatOptions(config))"); + } + + @Nonnull + private OrchestrationPrompt toOrchestrationPrompt(@Nonnull final Prompt prompt) { + val messages = toOrchestrationMessages(prompt.getInstructions()); + return new OrchestrationPrompt(Map.of(), messages); + } + + @Nonnull + private static com.sap.ai.sdk.orchestration.Message[] toOrchestrationMessages( + @Nonnull final List messages) { + final Function> mapper = + msg -> + switch (msg.getMessageType()) { + case SYSTEM: + yield List.of(new SystemMessage(msg.getText())); + case USER: + yield List.of(new UserMessage(msg.getText())); + case ASSISTANT: + val assistantMessage = new AssistantMessage(msg.getText()); + val springToolCalls = + ((org.springframework.ai.chat.messages.AssistantMessage) msg).getToolCalls(); + if (springToolCalls != null && !springToolCalls.isEmpty()) { + final List sdkToolCalls = + springToolCalls.stream() + .map(OrchestrationChatModel::toOrchestrationToolCall) + .toList(); + yield List.of(assistantMessage.withToolCalls(sdkToolCalls)); + } + yield List.of(assistantMessage); + case TOOL: + val toolResponses = ((ToolResponseMessage) msg).getResponses(); + yield toolResponses.stream() + .map( + r -> + (com.sap.ai.sdk.orchestration.Message) + new ToolMessage(r.id(), r.responseData())) + .toList(); + }; + return messages.stream() + .map(mapper) + .flatMap(List::stream) + .toArray(com.sap.ai.sdk.orchestration.Message[]::new); + } + + @Nonnull + private static MessageToolCall toOrchestrationToolCall(@Nonnull final ToolCall toolCall) { + return MessageToolCall.create() + .id(toolCall.id()) + .type(FUNCTION) + .function( + MessageToolCallFunction.create().name(toolCall.name()).arguments(toolCall.arguments())); + } } diff --git a/orchestration/src/test/java/com/sap/ai/sdk/orchestration/spring/OrchestrationChatModelTest.java b/orchestration/src/test/java/com/sap/ai/sdk/orchestration/spring/OrchestrationChatModelTest.java index 3379ba575..ca16b78a6 100644 --- a/orchestration/src/test/java/com/sap/ai/sdk/orchestration/spring/OrchestrationChatModelTest.java +++ b/orchestration/src/test/java/com/sap/ai/sdk/orchestration/spring/OrchestrationChatModelTest.java @@ -84,7 +84,7 @@ void testCompletion() { aResponse() .withBodyFile("templatingResponse.json") .withHeader("Content-Type", "application/json"))); - val result = client. call(prompt); + val result = client.call(prompt); assertThat(result).isNotNull(); assertThat(result.getResult().getOutput().getText()).isNotEmpty(); From 2eb92daa0a5caa4b0b399508e33b97ad8ad87cad Mon Sep 17 00:00:00 2001 From: Nourhan Shata Date: Mon, 27 Oct 2025 14:02:17 +0100 Subject: [PATCH 13/26] Updating Pom Dependencies --- pom.xml | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/pom.xml b/pom.xml index 8c70aae4f..b63762350 100644 --- a/pom.xml +++ b/pom.xml @@ -258,9 +258,9 @@ - org.slf4j - slf4j-simple - ${slf4j.version} + ch.qos.logback + logback-classic + 1.5.20 test @@ -365,7 +365,7 @@ org.tinylog - org.slf4j:slf4j-simple:*:*:test + ch.qos.logback:*:*:*:test @@ -554,9 +554,7 @@ com.google.code.findbugs:jsr305 - org.slf4j:slf4j-api - org.slf4j:jcl-over-slf4j - org.slf4j:slf4j-simple + ch.qos.logback:logback-classic org.projectlombok:lombok From 7a372203d06a3bd8a338bd631d77793242579088 Mon Sep 17 00:00:00 2001 From: I538344 Date: Mon, 27 Oct 2025 14:08:37 +0100 Subject: [PATCH 14/26] service bindings static --- .../ai/sdk/core/AiCoreServiceKeyAccessor.java | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/core/src/main/java/com/sap/ai/sdk/core/AiCoreServiceKeyAccessor.java b/core/src/main/java/com/sap/ai/sdk/core/AiCoreServiceKeyAccessor.java index c01e05862..d5364fccd 100644 --- a/core/src/main/java/com/sap/ai/sdk/core/AiCoreServiceKeyAccessor.java +++ b/core/src/main/java/com/sap/ai/sdk/core/AiCoreServiceKeyAccessor.java @@ -13,7 +13,6 @@ import io.vavr.Lazy; import java.util.HashMap; import java.util.List; -import java.util.concurrent.atomic.AtomicBoolean; import javax.annotation.Nonnull; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -27,10 +26,11 @@ @AllArgsConstructor class AiCoreServiceKeyAccessor implements ServiceBindingAccessor { static final String ENV_VAR_KEY = "AICORE_SERVICE_KEY"; - private static final AtomicBoolean INFO_LOG_EMITTED = new AtomicBoolean(false); private final Lazy dotenv; + private static List serviceBindings; + AiCoreServiceKeyAccessor() { this(Dotenv.configure().ignoreIfMissing().ignoreIfMalformed()); } @@ -42,6 +42,14 @@ class AiCoreServiceKeyAccessor implements ServiceBindingAccessor { @Nonnull @Override public List getServiceBindings() throws ServiceBindingAccessException { + // service bindings are immutable for the lifetime of the application + if (serviceBindings == null) { + serviceBindings = fetchServiceBindings(); + } + return serviceBindings; + } + + private List fetchServiceBindings() throws ServiceBindingAccessException { final String serviceKey; try { serviceKey = dotenv.get().get(ENV_VAR_KEY); @@ -52,15 +60,13 @@ public List getServiceBindings() throws ServiceBindingAccessExce log.debug("No service key found in environment variable {}", ENV_VAR_KEY); return List.of(); } - if (INFO_LOG_EMITTED.compareAndSet(false, true)) { - log.info( - """ - Found a service key in environment variable {}. - Using a service key is recommended for local testing only. - Bind the AI Core service to the application for productive usage. - """, - ENV_VAR_KEY); - } + log.info( + """ + Found a service key in environment variable {}. + Using a service key is recommended for local testing only. + Bind the AI Core service to the application for productive usage. + """, + ENV_VAR_KEY); val binding = createServiceBinding(serviceKey); return List.of(binding); From fcbd6fa646a1d3ecd17e6a125ee6dd1df6eb75d3 Mon Sep 17 00:00:00 2001 From: I538344 Date: Mon, 27 Oct 2025 14:16:54 +0100 Subject: [PATCH 15/26] fix tests --- .../java/com/sap/ai/sdk/core/AiCoreServiceKeyAccessor.java | 2 +- .../com/sap/ai/sdk/core/common/ClientResponseHandler.java | 7 ++++--- .../com/sap/ai/sdk/core/AiCoreServiceKeyAccessorTest.java | 6 ++++++ 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/core/src/main/java/com/sap/ai/sdk/core/AiCoreServiceKeyAccessor.java b/core/src/main/java/com/sap/ai/sdk/core/AiCoreServiceKeyAccessor.java index d5364fccd..262eadaa5 100644 --- a/core/src/main/java/com/sap/ai/sdk/core/AiCoreServiceKeyAccessor.java +++ b/core/src/main/java/com/sap/ai/sdk/core/AiCoreServiceKeyAccessor.java @@ -29,7 +29,7 @@ class AiCoreServiceKeyAccessor implements ServiceBindingAccessor { private final Lazy dotenv; - private static List serviceBindings; + static List serviceBindings; AiCoreServiceKeyAccessor() { this(Dotenv.configure().ignoreIfMissing().ignoreIfMalformed()); diff --git a/core/src/main/java/com/sap/ai/sdk/core/common/ClientResponseHandler.java b/core/src/main/java/com/sap/ai/sdk/core/common/ClientResponseHandler.java index b627e0223..112fbd17a 100644 --- a/core/src/main/java/com/sap/ai/sdk/core/common/ClientResponseHandler.java +++ b/core/src/main/java/com/sap/ai/sdk/core/common/ClientResponseHandler.java @@ -88,9 +88,10 @@ private T parseSuccess(@Nonnull final ClassicHttpResponse response) throws E { .getOrElseThrow(e -> exceptionFactory.build(message, e).setHttpResponse(response)); try { final T value = objectMapper.readValue(content, successType); - log.info( - "LLM request success with duration:{}", - response.getHeaders("x-upstream-service-time")[0].getValue()); + val timeHeaders = response.getHeaders("x-upstream-service-time"); + if (timeHeaders.length > 0) { + log.info("LLM request success with duration:{}", timeHeaders[0].getValue()); + } return value; } catch (final JsonProcessingException e) { log.error("Failed to parse response to type {}", successType); diff --git a/core/src/test/java/com/sap/ai/sdk/core/AiCoreServiceKeyAccessorTest.java b/core/src/test/java/com/sap/ai/sdk/core/AiCoreServiceKeyAccessorTest.java index cd7912269..c64efcce5 100644 --- a/core/src/test/java/com/sap/ai/sdk/core/AiCoreServiceKeyAccessorTest.java +++ b/core/src/test/java/com/sap/ai/sdk/core/AiCoreServiceKeyAccessorTest.java @@ -9,10 +9,16 @@ import com.sap.cloud.environment.servicebinding.api.exception.ServiceBindingAccessException; import io.github.cdimascio.dotenv.Dotenv; import java.util.Map; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; class AiCoreServiceKeyAccessorTest { + @BeforeEach + void resetServiceBindings() { + AiCoreServiceKeyAccessor.serviceBindings = null; + } + @Test void testValidDotenv() { var dotenv = spy(Dotenv.configure().filename("valid.testenv")); From fe25e7643adf8ad275c4cd67a7206a05e33779d8 Mon Sep 17 00:00:00 2001 From: Nourhan Shata Date: Mon, 27 Oct 2025 14:37:33 +0100 Subject: [PATCH 16/26] Added useful debug log but as an info one as log.debug() does not show/appear in terminal for some reason. --- .../java/com/sap/ai/sdk/core/common/ClientResponseHandler.java | 1 + 1 file changed, 1 insertion(+) diff --git a/core/src/main/java/com/sap/ai/sdk/core/common/ClientResponseHandler.java b/core/src/main/java/com/sap/ai/sdk/core/common/ClientResponseHandler.java index 112fbd17a..0e8a863bd 100644 --- a/core/src/main/java/com/sap/ai/sdk/core/common/ClientResponseHandler.java +++ b/core/src/main/java/com/sap/ai/sdk/core/common/ClientResponseHandler.java @@ -88,6 +88,7 @@ private T parseSuccess(@Nonnull final ClassicHttpResponse response) throws E { .getOrElseThrow(e -> exceptionFactory.build(message, e).setHttpResponse(response)); try { final T value = objectMapper.readValue(content, successType); + log.info("Response content: {}", content); val timeHeaders = response.getHeaders("x-upstream-service-time"); if (timeHeaders.length > 0) { log.info("LLM request success with duration:{}", timeHeaders[0].getValue()); From 35411592e3516c885b58a65779f91d9c3631f8db Mon Sep 17 00:00:00 2001 From: I538344 Date: Mon, 27 Oct 2025 15:07:42 +0100 Subject: [PATCH 17/26] logback config file --- .../ai/sdk/core/AiCoreServiceKeyAccessor.java | 10 ---------- .../core/common/ClientResponseHandler.java | 4 +++- .../core/AiCoreServiceKeyAccessorTest.java | 6 ------ logback.xml | 20 +++++++++++++++++++ pom.xml | 8 +++++++- .../src/main/resources/logback-spring.xml | 19 ------------------ 6 files changed, 30 insertions(+), 37 deletions(-) create mode 100644 logback.xml delete mode 100644 sample-code/spring-app/src/main/resources/logback-spring.xml diff --git a/core/src/main/java/com/sap/ai/sdk/core/AiCoreServiceKeyAccessor.java b/core/src/main/java/com/sap/ai/sdk/core/AiCoreServiceKeyAccessor.java index 262eadaa5..ee2fc47ba 100644 --- a/core/src/main/java/com/sap/ai/sdk/core/AiCoreServiceKeyAccessor.java +++ b/core/src/main/java/com/sap/ai/sdk/core/AiCoreServiceKeyAccessor.java @@ -29,8 +29,6 @@ class AiCoreServiceKeyAccessor implements ServiceBindingAccessor { private final Lazy dotenv; - static List serviceBindings; - AiCoreServiceKeyAccessor() { this(Dotenv.configure().ignoreIfMissing().ignoreIfMalformed()); } @@ -42,14 +40,6 @@ class AiCoreServiceKeyAccessor implements ServiceBindingAccessor { @Nonnull @Override public List getServiceBindings() throws ServiceBindingAccessException { - // service bindings are immutable for the lifetime of the application - if (serviceBindings == null) { - serviceBindings = fetchServiceBindings(); - } - return serviceBindings; - } - - private List fetchServiceBindings() throws ServiceBindingAccessException { final String serviceKey; try { serviceKey = dotenv.get().get(ENV_VAR_KEY); diff --git a/core/src/main/java/com/sap/ai/sdk/core/common/ClientResponseHandler.java b/core/src/main/java/com/sap/ai/sdk/core/common/ClientResponseHandler.java index 0e8a863bd..a2c37cf52 100644 --- a/core/src/main/java/com/sap/ai/sdk/core/common/ClientResponseHandler.java +++ b/core/src/main/java/com/sap/ai/sdk/core/common/ClientResponseHandler.java @@ -88,11 +88,13 @@ private T parseSuccess(@Nonnull final ClassicHttpResponse response) throws E { .getOrElseThrow(e -> exceptionFactory.build(message, e).setHttpResponse(response)); try { final T value = objectMapper.readValue(content, successType); - log.info("Response content: {}", content); val timeHeaders = response.getHeaders("x-upstream-service-time"); if (timeHeaders.length > 0) { log.info("LLM request success with duration:{}", timeHeaders[0].getValue()); + } else { + log.info("LLM request success"); } + log.debug("Response content:\n{}", content); return value; } catch (final JsonProcessingException e) { log.error("Failed to parse response to type {}", successType); diff --git a/core/src/test/java/com/sap/ai/sdk/core/AiCoreServiceKeyAccessorTest.java b/core/src/test/java/com/sap/ai/sdk/core/AiCoreServiceKeyAccessorTest.java index c64efcce5..cd7912269 100644 --- a/core/src/test/java/com/sap/ai/sdk/core/AiCoreServiceKeyAccessorTest.java +++ b/core/src/test/java/com/sap/ai/sdk/core/AiCoreServiceKeyAccessorTest.java @@ -9,16 +9,10 @@ import com.sap.cloud.environment.servicebinding.api.exception.ServiceBindingAccessException; import io.github.cdimascio.dotenv.Dotenv; import java.util.Map; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; class AiCoreServiceKeyAccessorTest { - @BeforeEach - void resetServiceBindings() { - AiCoreServiceKeyAccessor.serviceBindings = null; - } - @Test void testValidDotenv() { var dotenv = spy(Dotenv.configure().filename("valid.testenv")); diff --git a/logback.xml b/logback.xml new file mode 100644 index 000000000..ab0026848 --- /dev/null +++ b/logback.xml @@ -0,0 +1,20 @@ + + + + + + + ${LOG_PATTERN} + + + + + + + + + + + + + diff --git a/pom.xml b/pom.xml index b63762350..531148420 100644 --- a/pom.xml +++ b/pom.xml @@ -71,6 +71,7 @@ 3.27.1 4.38.0 2.20.0 + 1.5.20 1.15.5 20250517 @@ -260,7 +261,7 @@ ch.qos.logback logback-classic - 1.5.20 + ${logback.version} test @@ -596,6 +597,11 @@ org.apache.maven.plugins maven-surefire-plugin ${surefire.version} + + + ${project.rootdir}/logback.xml + + diff --git a/sample-code/spring-app/src/main/resources/logback-spring.xml b/sample-code/spring-app/src/main/resources/logback-spring.xml deleted file mode 100644 index b1d413914..000000000 --- a/sample-code/spring-app/src/main/resources/logback-spring.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - - - - - - - - - - From 649f2651c0b5d642b55fee8805320eb87a0e6fe0 Mon Sep 17 00:00:00 2001 From: Roshin Rajan Panackal Date: Wed, 29 Oct 2025 14:56:43 +0100 Subject: [PATCH 18/26] Per request logging update --- .../sap/ai/sdk/core/DeploymentResolver.java | 1 + .../core/common/ClientResponseHandler.java | 28 +++++++++++++------ .../openai/OpenAiChatCompletionResponse.java | 13 +++++++-- .../foundationmodels/openai/OpenAiClient.java | 27 ++++++++++++++++++ .../openai/spring/OpenAiChatModel.java | 5 +++- logback.xml | 2 +- .../OrchestrationHttpExecutor.java | 28 +++++++++++++++++-- .../spring/OrchestrationChatModel.java | 6 +++- 8 files changed, 93 insertions(+), 17 deletions(-) diff --git a/core/src/main/java/com/sap/ai/sdk/core/DeploymentResolver.java b/core/src/main/java/com/sap/ai/sdk/core/DeploymentResolver.java index 66d674f8d..f42725cc5 100644 --- a/core/src/main/java/com/sap/ai/sdk/core/DeploymentResolver.java +++ b/core/src/main/java/com/sap/ai/sdk/core/DeploymentResolver.java @@ -46,6 +46,7 @@ void reloadDeployments(@Nonnull final String resourceGroup) { try { val apiClient = new DeploymentApi(service); val deployments = new HashSet<>(apiClient.query(resourceGroup).getResources()); + log.info("Found {} deployments in resource group '{}'", deployments.size(), resourceGroup); cache.put(resourceGroup, deployments); } catch (final RuntimeException e) { throw new DeploymentResolutionException( diff --git a/core/src/main/java/com/sap/ai/sdk/core/common/ClientResponseHandler.java b/core/src/main/java/com/sap/ai/sdk/core/common/ClientResponseHandler.java index a2c37cf52..5095289aa 100644 --- a/core/src/main/java/com/sap/ai/sdk/core/common/ClientResponseHandler.java +++ b/core/src/main/java/com/sap/ai/sdk/core/common/ClientResponseHandler.java @@ -18,6 +18,7 @@ import org.apache.hc.core5.http.HttpEntity; import org.apache.hc.core5.http.io.HttpClientResponseHandler; import org.apache.hc.core5.http.io.entity.EntityUtils; +import org.slf4j.MDC; /** * Parse incoming JSON responses and handles any errors. For internal use only. @@ -86,16 +87,10 @@ private T parseSuccess(@Nonnull final ClassicHttpResponse response) throws E { val content = tryGetContent(responseEntity) .getOrElseThrow(e -> exceptionFactory.build(message, e).setHttpResponse(response)); + logResponseSuccess(response); + try { - final T value = objectMapper.readValue(content, successType); - val timeHeaders = response.getHeaders("x-upstream-service-time"); - if (timeHeaders.length > 0) { - log.info("LLM request success with duration:{}", timeHeaders[0].getValue()); - } else { - log.info("LLM request success"); - } - log.debug("Response content:\n{}", content); - return value; + return objectMapper.readValue(content, successType); } catch (final JsonProcessingException e) { log.error("Failed to parse response to type {}", successType); throw exceptionFactory.build("Failed to parse response", e).setHttpResponse(response); @@ -177,4 +172,19 @@ private static String getErrorMessage( val message = Optional.ofNullable(additionalMessage).orElse(""); return message.isEmpty() ? baseErrorMessage : "%s: %s".formatted(baseErrorMessage, message); } + + private static void logResponseSuccess(final @Nonnull ClassicHttpResponse response) { + val latency = + Optional.ofNullable(response.getFirstHeader("x-upstream-service-time")) + .map(h -> h.getValue() + "ms") + .orElseGet(() -> "unknown"); + val entityLength = response.getEntity().getContentLength(); + val sizeInfo = entityLength >= 0 ? String.format("%.1fKB", entityLength / 1024.0) : "unknown"; + log.debug( + "[reqId={}] {} request completed successfully with latency={}, size={}.", + MDC.get("reqId"), + MDC.get("service"), + latency, + sizeInfo); + } } diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionResponse.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionResponse.java index ec9fea99e..e5e0804ea 100644 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionResponse.java +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionResponse.java @@ -7,13 +7,16 @@ import com.sap.ai.sdk.foundationmodels.openai.generated.model.CompletionUsage; import com.sap.ai.sdk.foundationmodels.openai.generated.model.CreateChatCompletionResponse; import com.sap.ai.sdk.foundationmodels.openai.generated.model.CreateChatCompletionResponseChoicesInner; +import java.util.Collections; import java.util.List; import java.util.Objects; +import java.util.Optional; import javax.annotation.Nonnull; import lombok.Getter; import lombok.RequiredArgsConstructor; import lombok.Setter; import lombok.Value; +import lombok.extern.slf4j.Slf4j; /** * Represents the output of an OpenAI chat completion. * @@ -23,6 +26,7 @@ @Value @RequiredArgsConstructor(access = PACKAGE) @Setter(value = NONE) +@Slf4j public class OpenAiChatCompletionResponse { /** The original response from the OpenAI API. */ @Nonnull CreateChatCompletionResponse originalResponse; @@ -110,7 +114,12 @@ public OpenAiAssistantMessage getMessage() { */ @Nonnull public List executeTools() { - final var tools = originalRequest.getToolsExecutable(); - return OpenAiTool.execute(tools != null ? tools : List.of(), getMessage()); + final var tools = + Optional.ofNullable(originalRequest.getToolsExecutable()).orElseGet(Collections::emptyList); + if (!tools.isEmpty()) { + final var toolNames = tools.stream().map(OpenAiTool::getName).toList(); + log.debug("Executing {} tool call(s) - {}", toolNames.size(), toolNames); + } + return OpenAiTool.execute(tools, getMessage()); } } diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiClient.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiClient.java index e24eab3e5..59a0811b9 100644 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiClient.java +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiClient.java @@ -30,16 +30,19 @@ import java.io.IOException; import java.util.ArrayList; import java.util.List; +import java.util.UUID; import java.util.stream.Stream; import javax.annotation.Nonnull; import javax.annotation.Nullable; import lombok.AccessLevel; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import lombok.val; import org.apache.hc.client5.http.classic.methods.HttpPost; import org.apache.hc.core5.http.ContentType; import org.apache.hc.core5.http.io.entity.StringEntity; import org.apache.hc.core5.http.message.BasicClassicHttpRequest; +import org.slf4j.MDC; /** Client for interacting with OpenAI models. */ @Slf4j @@ -417,6 +420,7 @@ private T execute( @Nonnull final Class responseType) { final var request = new HttpPost(path); serializeAndSetHttpEntity(request, payload, this.customHeaders); + MDC.put("endpoint", path); return executeRequest(request, responseType); } @@ -427,6 +431,7 @@ private Stream executeStream( @Nonnull final Class deltaType) { final var request = new HttpPost(path); serializeAndSetHttpEntity(request, payload, this.customHeaders); + MDC.put("endpoint", path); return streamRequest(request, deltaType); } @@ -449,10 +454,15 @@ private T executeRequest( final BasicClassicHttpRequest request, @Nonnull final Class responseType) { try { final var client = ApacheHttpClient5Accessor.getHttpClient(destination); + MDC.put("destination", ((HttpDestination) destination).getUri().toASCIIString()); + MDC.put("mode", "streaming"); + logRequestStart(); return client.execute( request, new ClientResponseHandler<>(responseType, OpenAiError.class, FACTORY)); } catch (final IOException e) { throw new OpenAiClientException("Request to OpenAI model failed", e).setHttpRequest(request); + } finally { + MDC.clear(); } } @@ -461,11 +471,28 @@ private Stream streamRequest( final BasicClassicHttpRequest request, @Nonnull final Class deltaType) { try { final var client = ApacheHttpClient5Accessor.getHttpClient(destination); + MDC.put("destination", ((HttpDestination) destination).getUri().toASCIIString()); + MDC.put("mode", "streaming"); + logRequestStart(); return new ClientStreamingHandler<>(deltaType, OpenAiError.class, FACTORY) .objectMapper(JACKSON) .handleStreamingResponse(client.executeOpen(null, request, null)); } catch (final IOException e) { throw new OpenAiClientException("Request to OpenAI model failed", e).setHttpRequest(request); + } finally { + MDC.clear(); } } + + private static void logRequestStart() { + val reqId = UUID.randomUUID().toString().substring(0, 8); + MDC.put("reqId", reqId); + MDC.put("service", "Orchestration"); + log.debug( + "[reqId={}] Starting OpenAI {} request to {}, destination={}", + reqId, + MDC.get("mode"), + MDC.get("endpoint"), + MDC.get("destination")); + } } diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/spring/OpenAiChatModel.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/spring/OpenAiChatModel.java index d670b121e..280bbee7c 100644 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/spring/OpenAiChatModel.java +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/spring/OpenAiChatModel.java @@ -69,9 +69,12 @@ public ChatResponse call(@Nonnull final Prompt prompt) { val response = new ChatResponse(toGenerations(result)); if (options != null && isInternalToolExecutionEnabled(options) && response.hasToolCalls()) { + val toolCalls = + response.getResult().getOutput().getToolCalls().stream().map(ToolCall::name).toList(); + log.info("Executing {} tool call(s) - {}", toolCalls.size(), toolCalls); val toolExecutionResult = toolCallingManager.executeToolCalls(prompt, response); // Send the tool execution result back to the model. - log.info("Re-invoking model with tool execution results."); + log.debug("Re-invoking model with tool execution results."); return call(new Prompt(toolExecutionResult.conversationHistory(), options)); } return response; diff --git a/logback.xml b/logback.xml index ab0026848..2bbaaa365 100644 --- a/logback.xml +++ b/logback.xml @@ -1,6 +1,6 @@ - + diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationHttpExecutor.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationHttpExecutor.java index 65f4e3ece..0399f8664 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationHttpExecutor.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationHttpExecutor.java @@ -16,6 +16,7 @@ import com.sap.cloud.sdk.cloudplatform.connectivity.exception.HttpClientInstantiationException; import java.io.IOException; import java.util.List; +import java.util.UUID; import java.util.function.Supplier; import java.util.stream.Stream; import javax.annotation.Nonnull; @@ -25,6 +26,7 @@ import org.apache.hc.client5.http.classic.methods.HttpPost; import org.apache.hc.core5.http.ContentType; import org.apache.hc.core5.http.io.entity.StringEntity; +import org.slf4j.MDC; @Slf4j class OrchestrationHttpExecutor { @@ -45,7 +47,6 @@ T execute( @Nonnull final List
customHeaders) { try { val json = JACKSON.writeValueAsString(payload); - log.debug("Successfully serialized request into JSON payload"); val request = new HttpPost(path); request.setEntity(new StringEntity(json, ContentType.APPLICATION_JSON)); customHeaders.forEach(h -> request.addHeader(h.getName(), h.getValue())); @@ -55,6 +56,9 @@ T execute( val handler = new ClientResponseHandler<>(responseType, OrchestrationError.Synchronous.class, FACTORY) .objectMapper(JACKSON); + MDC.put("endpoint", path); + MDC.put("mode", "synchronous"); + logRequestStart(); return client.execute(request, handler); } catch (JsonProcessingException e) { @@ -66,6 +70,8 @@ T execute( | IOException e) { throw new OrchestrationClientException( "Request to Orchestration service failed for " + path, e); + } finally { + MDC.clear(); } } @@ -81,7 +87,9 @@ Stream stream( customHeaders.forEach(h -> request.addHeader(h.getName(), h.getValue())); val client = getHttpClient(); - + MDC.put("endpoint", path); + MDC.put("method", "streaming"); + logRequestStart(); return new ClientStreamingHandler<>( OrchestrationChatCompletionDelta.class, OrchestrationError.Streaming.class, FACTORY) .objectMapper(JACKSON) @@ -93,13 +101,27 @@ Stream stream( } catch (IOException e) { throw new OrchestrationClientException( "Streaming request to the Orchestration service failed", e); + } finally { + MDC.clear(); } } @Nonnull private HttpClient getHttpClient() { val destination = destinationSupplier.get(); - log.debug("Using destination {} to connect to orchestration service", destination); + MDC.put("destination", destination.getUri().toASCIIString()); return ApacheHttpClient5Accessor.getHttpClient(destination); } + + private static void logRequestStart() { + val reqId = UUID.randomUUID().toString().substring(0, 8); + MDC.put("reqId", reqId); + MDC.put("service", "Orchestration"); + log.debug( + "[reqId={}] Starting Orchestration {} request to {}, destination={}", + reqId, + MDC.get("mode"), + MDC.get("endpoint"), + MDC.get("destination")); + } } diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/spring/OrchestrationChatModel.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/spring/OrchestrationChatModel.java index 15d7667a8..1e90d0f47 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/spring/OrchestrationChatModel.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/spring/OrchestrationChatModel.java @@ -72,9 +72,13 @@ public ChatResponse call(@Nonnull final Prompt prompt) { if (ToolCallingChatOptions.isInternalToolExecutionEnabled(prompt.getOptions()) && response.hasToolCalls()) { + + val toolCalls = + response.getResult().getOutput().getToolCalls().stream().map(ToolCall::name).toList(); + log.debug("Executing {} tool call(s) - {}", toolCalls.size(), toolCalls); val toolExecutionResult = toolCallingManager.executeToolCalls(prompt, response); // Send the tool execution result back to the model. - log.info("Re-invoking model with tool execution results."); + log.debug("Re-invoking LLM with tool execution results."); return call(new Prompt(toolExecutionResult.conversationHistory(), prompt.getOptions())); } return response; From 01661ae2be1c9626fe31f8b45080953829cdbfa1 Mon Sep 17 00:00:00 2001 From: Roshin Rajan Panackal Date: Wed, 29 Oct 2025 16:06:58 +0100 Subject: [PATCH 19/26] Review suggestion --- .../core/common/ClientResponseHandler.java | 6 +++--- .../foundationmodels/openai/OpenAiClient.java | 4 ++-- .../src/main/resources/logback-spring.xml | 20 +++++++++++++++++++ 3 files changed, 25 insertions(+), 5 deletions(-) create mode 100644 sample-code/spring-app/src/main/resources/logback-spring.xml diff --git a/core/src/main/java/com/sap/ai/sdk/core/common/ClientResponseHandler.java b/core/src/main/java/com/sap/ai/sdk/core/common/ClientResponseHandler.java index 5095289aa..dac4bcdb6 100644 --- a/core/src/main/java/com/sap/ai/sdk/core/common/ClientResponseHandler.java +++ b/core/src/main/java/com/sap/ai/sdk/core/common/ClientResponseHandler.java @@ -174,17 +174,17 @@ private static String getErrorMessage( } private static void logResponseSuccess(final @Nonnull ClassicHttpResponse response) { - val latency = + val duration = Optional.ofNullable(response.getFirstHeader("x-upstream-service-time")) .map(h -> h.getValue() + "ms") .orElseGet(() -> "unknown"); val entityLength = response.getEntity().getContentLength(); val sizeInfo = entityLength >= 0 ? String.format("%.1fKB", entityLength / 1024.0) : "unknown"; log.debug( - "[reqId={}] {} request completed successfully with latency={}, size={}.", + "[reqId={}] {} request completed successfully with duration={}, size={}.", MDC.get("reqId"), MDC.get("service"), - latency, + duration, sizeInfo); } } diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiClient.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiClient.java index 59a0811b9..f1628a3f9 100644 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiClient.java +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiClient.java @@ -455,7 +455,7 @@ private T executeRequest( try { final var client = ApacheHttpClient5Accessor.getHttpClient(destination); MDC.put("destination", ((HttpDestination) destination).getUri().toASCIIString()); - MDC.put("mode", "streaming"); + MDC.put("mode", "synchronous"); logRequestStart(); return client.execute( request, new ClientResponseHandler<>(responseType, OpenAiError.class, FACTORY)); @@ -487,7 +487,7 @@ private Stream streamRequest( private static void logRequestStart() { val reqId = UUID.randomUUID().toString().substring(0, 8); MDC.put("reqId", reqId); - MDC.put("service", "Orchestration"); + MDC.put("service", "OpenAI"); log.debug( "[reqId={}] Starting OpenAI {} request to {}, destination={}", reqId, diff --git a/sample-code/spring-app/src/main/resources/logback-spring.xml b/sample-code/spring-app/src/main/resources/logback-spring.xml new file mode 100644 index 000000000..7aac1238d --- /dev/null +++ b/sample-code/spring-app/src/main/resources/logback-spring.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + From 87e5ccbc0313511af4d23d0e8c8238319643f6d0 Mon Sep 17 00:00:00 2001 From: Roshin Rajan Panackal Date: Wed, 29 Oct 2025 16:28:53 +0100 Subject: [PATCH 20/26] fix minor --- .../com/sap/ai/sdk/orchestration/OrchestrationHttpExecutor.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationHttpExecutor.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationHttpExecutor.java index 0399f8664..4ae83aa15 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationHttpExecutor.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationHttpExecutor.java @@ -88,7 +88,7 @@ Stream stream( val client = getHttpClient(); MDC.put("endpoint", path); - MDC.put("method", "streaming"); + MDC.put("mode", "streaming"); logRequestStart(); return new ClientStreamingHandler<>( OrchestrationChatCompletionDelta.class, OrchestrationError.Streaming.class, FACTORY) From 485ccc02f7dab8137c6a7af0a3a2d9180759104d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20D=C3=BCmont?= <22489773+newtork@users.noreply.github.com> Date: Thu, 30 Oct 2025 16:21:42 +0100 Subject: [PATCH 21/26] Update foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionResponse.java --- .../openai/OpenAiChatCompletionResponse.java | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionResponse.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionResponse.java index e5e0804ea..2a15fbaa6 100644 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionResponse.java +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionResponse.java @@ -114,11 +114,10 @@ public OpenAiAssistantMessage getMessage() { */ @Nonnull public List executeTools() { - final var tools = - Optional.ofNullable(originalRequest.getToolsExecutable()).orElseGet(Collections::emptyList); - if (!tools.isEmpty()) { - final var toolNames = tools.stream().map(OpenAiTool::getName).toList(); - log.debug("Executing {} tool call(s) - {}", toolNames.size(), toolNames); + final var tools = Optional.ofNullable(originalRequest.getToolsExecutable()).orElse(List.of()); + if(log.isDebugEnabled() && !tools.isEmpty()) { + final var toolNames = tools.stream().map(OpenAiTool::getName).toList(); + log.debug("Executing {} tool call(s) - {}", toolNames.size(), toolNames); } return OpenAiTool.execute(tools, getMessage()); } From ebcb1bd9eeaeedc0006c5a89a350a6e568cf0cee Mon Sep 17 00:00:00 2001 From: SAP Cloud SDK Bot Date: Thu, 30 Oct 2025 15:22:27 +0000 Subject: [PATCH 22/26] Formatting --- .../openai/OpenAiChatCompletionResponse.java | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionResponse.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionResponse.java index 2a15fbaa6..f92c38e6c 100644 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionResponse.java +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionResponse.java @@ -7,7 +7,6 @@ import com.sap.ai.sdk.foundationmodels.openai.generated.model.CompletionUsage; import com.sap.ai.sdk.foundationmodels.openai.generated.model.CreateChatCompletionResponse; import com.sap.ai.sdk.foundationmodels.openai.generated.model.CreateChatCompletionResponseChoicesInner; -import java.util.Collections; import java.util.List; import java.util.Objects; import java.util.Optional; @@ -115,9 +114,9 @@ public OpenAiAssistantMessage getMessage() { @Nonnull public List executeTools() { final var tools = Optional.ofNullable(originalRequest.getToolsExecutable()).orElse(List.of()); - if(log.isDebugEnabled() && !tools.isEmpty()) { - final var toolNames = tools.stream().map(OpenAiTool::getName).toList(); - log.debug("Executing {} tool call(s) - {}", toolNames.size(), toolNames); + if (log.isDebugEnabled() && !tools.isEmpty()) { + final var toolNames = tools.stream().map(OpenAiTool::getName).toList(); + log.debug("Executing {} tool call(s) - {}", toolNames.size(), toolNames); } return OpenAiTool.execute(tools, getMessage()); } From 74094fb323283bf142b58d9de41734bf8c9b503e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20D=C3=BCmont?= Date: Thu, 30 Oct 2025 16:26:34 +0100 Subject: [PATCH 23/26] Minor format --- .../sdk/core/common/ClientResponseHandler.java | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/core/src/main/java/com/sap/ai/sdk/core/common/ClientResponseHandler.java b/core/src/main/java/com/sap/ai/sdk/core/common/ClientResponseHandler.java index dac4bcdb6..3cad0bd6d 100644 --- a/core/src/main/java/com/sap/ai/sdk/core/common/ClientResponseHandler.java +++ b/core/src/main/java/com/sap/ai/sdk/core/common/ClientResponseHandler.java @@ -174,17 +174,14 @@ private static String getErrorMessage( } private static void logResponseSuccess(final @Nonnull ClassicHttpResponse response) { - val duration = - Optional.ofNullable(response.getFirstHeader("x-upstream-service-time")) - .map(h -> h.getValue() + "ms") - .orElseGet(() -> "unknown"); + if (!log.isDebugEnabled()) { + return; + } + val headerTime = Optional.ofNullable(response.getFirstHeader("x-upstream-service-time")); + val duration = headerTime.map(h -> h.getValue() + "ms").orElseGet(() -> "unknown"); val entityLength = response.getEntity().getContentLength(); val sizeInfo = entityLength >= 0 ? String.format("%.1fKB", entityLength / 1024.0) : "unknown"; - log.debug( - "[reqId={}] {} request completed successfully with duration={}, size={}.", - MDC.get("reqId"), - MDC.get("service"), - duration, - sizeInfo); + val msg = "[reqId={}] {} request completed successfully with duration={}, size={}."; + log.debug(msg, MDC.get("reqId"), MDC.get("service"), duration, sizeInfo); } } From 6e5fc2b05a78ee7e21f32e93d3dc8c6162930255 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20D=C3=BCmont?= Date: Thu, 30 Oct 2025 16:30:36 +0100 Subject: [PATCH 24/26] Minor format --- .../sdk/orchestration/OrchestrationHttpExecutor.java | 9 +++------ .../orchestration/spring/OrchestrationChatModel.java | 11 ++++++++--- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationHttpExecutor.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationHttpExecutor.java index 4ae83aa15..d7b488ca8 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationHttpExecutor.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationHttpExecutor.java @@ -117,11 +117,8 @@ private static void logRequestStart() { val reqId = UUID.randomUUID().toString().substring(0, 8); MDC.put("reqId", reqId); MDC.put("service", "Orchestration"); - log.debug( - "[reqId={}] Starting Orchestration {} request to {}, destination={}", - reqId, - MDC.get("mode"), - MDC.get("endpoint"), - MDC.get("destination")); + + val msg = "[reqId={}] Starting Orchestration {} request to {}, destination={}"; + log.debug(msg, reqId, MDC.get("mode"), MDC.get("endpoint"), MDC.get("destination")); } } diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/spring/OrchestrationChatModel.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/spring/OrchestrationChatModel.java index 1e90d0f47..ababae33c 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/spring/OrchestrationChatModel.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/spring/OrchestrationChatModel.java @@ -15,6 +15,7 @@ import java.util.List; import java.util.Map; import java.util.function.Function; +import java.util.stream.Collectors; import javax.annotation.Nonnull; import lombok.extern.slf4j.Slf4j; import lombok.val; @@ -73,10 +74,14 @@ public ChatResponse call(@Nonnull final Prompt prompt) { if (ToolCallingChatOptions.isInternalToolExecutionEnabled(prompt.getOptions()) && response.hasToolCalls()) { - val toolCalls = - response.getResult().getOutput().getToolCalls().stream().map(ToolCall::name).toList(); - log.debug("Executing {} tool call(s) - {}", toolCalls.size(), toolCalls); + if (log.isDebugEnabled()) { + val tools = response.getResult().getOutput().getToolCalls(); + val toolsStr = tools.stream().map(ToolCall::name).collect(Collectors.joining(", ")); + log.debug("Executing {} tool call(s) - {}", tools.size(), toolsStr); + } + val toolExecutionResult = toolCallingManager.executeToolCalls(prompt, response); + // Send the tool execution result back to the model. log.debug("Re-invoking LLM with tool execution results."); return call(new Prompt(toolExecutionResult.conversationHistory(), prompt.getOptions())); From 1d94b4c7e80d45ad733600e32d7dcfce8662033f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20D=C3=BCmont?= Date: Thu, 30 Oct 2025 16:35:51 +0100 Subject: [PATCH 25/26] Fix PMD --- .../foundationmodels/openai/OpenAiChatCompletionResponse.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionResponse.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionResponse.java index f92c38e6c..1cd42e86c 100644 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionResponse.java +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionResponse.java @@ -113,7 +113,7 @@ public OpenAiAssistantMessage getMessage() { */ @Nonnull public List executeTools() { - final var tools = Optional.ofNullable(originalRequest.getToolsExecutable()).orElse(List.of()); + final var tools = Optional.ofNullable(originalRequest.getToolsExecutable()).orElseGet(List::of); if (log.isDebugEnabled() && !tools.isEmpty()) { final var toolNames = tools.stream().map(OpenAiTool::getName).toList(); log.debug("Executing {} tool call(s) - {}", toolNames.size(), toolNames); From 4adb968c90261a8617c0c5c0811cfc7a65a3c18f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20D=C3=BCmont?= Date: Thu, 30 Oct 2025 16:56:48 +0100 Subject: [PATCH 26/26] Fix jacoco --- foundation-models/openai/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/foundation-models/openai/pom.xml b/foundation-models/openai/pom.xml index 2e228887a..f8e1fd0a6 100644 --- a/foundation-models/openai/pom.xml +++ b/foundation-models/openai/pom.xml @@ -41,7 +41,7 @@ 81% 91% 88% - 79% + 78% 90% 92%