From 6ebb2db13196d8afae0da807d085504e68bce6eb Mon Sep 17 00:00:00 2001 From: Roshin Rajan Panackal Date: Mon, 7 Apr 2025 11:39:31 +0200 Subject: [PATCH 01/36] feat: add methods to convert function call arguments to Map and specified object type --- .../openai/OpenAiFunctionCall.java | 36 +++++++++++++++++++ .../ai/sdk/app/services/OpenAiServiceV2.java | 12 +------ 2 files changed, 37 insertions(+), 11 deletions(-) diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionCall.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionCall.java index c3668d26b..b732eaeda 100644 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionCall.java +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionCall.java @@ -1,6 +1,9 @@ package com.sap.ai.sdk.foundationmodels.openai; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; import com.google.common.annotations.Beta; +import java.util.Map; import javax.annotation.Nonnull; import lombok.AllArgsConstructor; import lombok.Value; @@ -22,4 +25,37 @@ public class OpenAiFunctionCall implements OpenAiToolCall { /** The arguments for the function call, encoded as a JSON string. */ @Nonnull String arguments; + + /** + * Returns the arguments as a {@code Map}. + * + * @return the arguments as a map + * @throws IllegalArgumentException if parsing fails + */ + public Map getArgumentsAsMap() throws IllegalArgumentException { + try { + return OpenAiUtils.getOpenAiObjectMapper() + .readValue(getArguments(), new TypeReference>() {}); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException( + "Failed to parse given JSON string to Map", e); + } + } + + /** + * Returns the arguments as an object of the specified class. + * + * @param clazz the class to convert the arguments to + * @param the type of the class + * @return the arguments as an object of the specified class + * @throws IllegalArgumentException if parsing fails + */ + public T getArgumentsAsObject(Class clazz) throws IllegalArgumentException { + try { + return OpenAiUtils.getOpenAiObjectMapper().readValue(getArguments(), clazz); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException( + "Failed to parse given JSON string to " + clazz.getTypeName(), e); + } + } } diff --git a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OpenAiServiceV2.java b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OpenAiServiceV2.java index 8eef87928..b604caf00 100644 --- a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OpenAiServiceV2.java +++ b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OpenAiServiceV2.java @@ -5,7 +5,6 @@ import static com.sap.ai.sdk.foundationmodels.openai.OpenAiModel.TEXT_EMBEDDING_3_SMALL; import static com.sap.ai.sdk.foundationmodels.openai.generated.model.ChatCompletionTool.TypeEnum.FUNCTION; -import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.JsonMappingException; import com.fasterxml.jackson.databind.ObjectMapper; @@ -131,7 +130,7 @@ public OpenAiChatCompletionResponse chatCompletionToolExecution( } final WeatherMethod.Request arguments = - parseJson(functionCall.getArguments(), WeatherMethod.Request.class); + functionCall.getArgumentsAsObject(WeatherMethod.Request.class); final WeatherMethod.Response weatherMethod = WeatherMethod.getCurrentWeather(arguments); messages.add(OpenAiMessage.tool(weatherMethod.toString(), functionCall.getId())); @@ -140,15 +139,6 @@ public OpenAiChatCompletionResponse chatCompletionToolExecution( return OpenAiClient.forModel(GPT_4O_MINI).chatCompletion(request.withMessages(messages)); } - @Nonnull - private static T parseJson(@Nonnull final String rawJson, @Nonnull final Class clazz) { - try { - return JACKSON.readValue(rawJson, clazz); - } catch (JsonProcessingException e) { - throw new IllegalArgumentException("Failed to parse tool call arguments: " + rawJson, e); - } - } - @Nonnull private static Map generateSchema(@Nonnull final Class clazz) { final var jsonSchemaGenerator = new JsonSchemaGenerator(JACKSON); From f7e90880a44993806ef009ceee1ad96a5f612158 Mon Sep 17 00:00:00 2001 From: Roshin Rajan Panackal Date: Mon, 7 Apr 2025 17:14:56 +0200 Subject: [PATCH 02/36] feat: introduce OpenAiFunctionTool and enhance OpenAiChatCompletionRequest with tool handling --- .../openai/OpenAiChatCompletionRequest.java | 25 ++++++++- .../openai/OpenAiFunctionCall.java | 15 +---- .../openai/OpenAiFunctionTool.java | 56 +++++++++++++++++++ .../foundationmodels/openai/OpenAiTool.java | 3 + .../foundationmodels/openai/OpenAiUtils.java | 23 ++++++++ .../ai/sdk/app/services/OpenAiServiceV2.java | 18 +++--- 6 files changed, 115 insertions(+), 25 deletions(-) create mode 100644 foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionTool.java create mode 100644 foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiTool.java diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequest.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequest.java index 5f308a1c4..27705052c 100644 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequest.java +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequest.java @@ -283,7 +283,30 @@ public OpenAiChatCompletionRequest withToolChoice(@Nonnull final OpenAiToolChoic } /** - * Converts the request to a generated model class CreateChatCompletionRequest. + * Sets the tools to be used in the request with convenience class {@code OpenAiTool}. + * + * @param tools the list of tools to be used + * @return a new OpenAiChatCompletionRequest instance with the specified tools + */ + @Nonnull + public OpenAiChatCompletionRequest withOpenAiTools(@Nonnull final List tools) { + return this.withTools( + tools.stream() + .map( + tool -> { + if (tool instanceof OpenAiFunctionTool) { + return ((OpenAiFunctionTool) tool).createChatCompletionTool(); + } else { + throw new IllegalArgumentException( + "Unsupported tool type: " + tool.getClass().getName()); + } + }) + .toList()); + } + + /** + * tool.getClass().getName()); } Converts the request to a generated model class + * CreateChatCompletionRequest. * * @return the CreateChatCompletionRequest */ diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionCall.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionCall.java index b732eaeda..cfdd0de1d 100644 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionCall.java +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionCall.java @@ -33,13 +33,7 @@ public class OpenAiFunctionCall implements OpenAiToolCall { * @throws IllegalArgumentException if parsing fails */ public Map getArgumentsAsMap() throws IllegalArgumentException { - try { - return OpenAiUtils.getOpenAiObjectMapper() - .readValue(getArguments(), new TypeReference>() {}); - } catch (JsonProcessingException e) { - throw new IllegalArgumentException( - "Failed to parse given JSON string to Map", e); - } + return OpenAiUtils.parseJson(getArguments(), new TypeReference>() {}); } /** @@ -51,11 +45,6 @@ public Map getArgumentsAsMap() throws IllegalArgumentException { * @throws IllegalArgumentException if parsing fails */ public T getArgumentsAsObject(Class clazz) throws IllegalArgumentException { - try { - return OpenAiUtils.getOpenAiObjectMapper().readValue(getArguments(), clazz); - } catch (JsonProcessingException e) { - throw new IllegalArgumentException( - "Failed to parse given JSON string to " + clazz.getTypeName(), e); - } + return OpenAiUtils.parseJson(getArguments(), clazz); } } diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionTool.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionTool.java new file mode 100644 index 000000000..4cb1510c5 --- /dev/null +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionTool.java @@ -0,0 +1,56 @@ +package com.sap.ai.sdk.foundationmodels.openai; + +import static com.sap.ai.sdk.foundationmodels.openai.generated.model.ChatCompletionTool.TypeEnum.FUNCTION; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.module.jsonSchema.JsonSchema; +import com.fasterxml.jackson.module.jsonSchema.JsonSchemaGenerator; +import com.google.common.annotations.Beta; +import com.sap.ai.sdk.foundationmodels.openai.generated.model.ChatCompletionTool; +import com.sap.ai.sdk.foundationmodels.openai.generated.model.FunctionObject; +import java.util.Map; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Value; +import lombok.With; + +@Beta +@Value +@With +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class OpenAiFunctionTool implements OpenAiTool { + + @Nonnull String name; + @Nonnull Class clazz; + + @Nullable String description; + @Nullable Boolean strict; + + public OpenAiFunctionTool(@Nonnull final String name, @Nonnull final Class clazz) { + this(name, clazz, null, null); + } + + ChatCompletionTool createChatCompletionTool() { + var objectMapper = new ObjectMapper(); + JsonSchema schema = null; + try { + schema = new JsonSchemaGenerator(objectMapper).generateSchema(clazz); + } catch (JsonMappingException e) { + throw new IllegalArgumentException("Could not generate schema for " + clazz.getTypeName(), e); + } + + var schemaMap = objectMapper.convertValue(schema, new TypeReference>() {}); + + final var function = + new FunctionObject() + .name(getName()) + .description(getDescription()) + .parameters(schemaMap) + .strict(getStrict()); + return new ChatCompletionTool().type(FUNCTION).function(function); + } +} diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiTool.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiTool.java new file mode 100644 index 000000000..3389a87d2 --- /dev/null +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiTool.java @@ -0,0 +1,3 @@ +package com.sap.ai.sdk.foundationmodels.openai; + +public sealed interface OpenAiTool permits OpenAiFunctionTool {} diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiUtils.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiUtils.java index 0b12565b9..3a16ba3a5 100644 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiUtils.java +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiUtils.java @@ -2,6 +2,7 @@ import static com.sap.ai.sdk.core.JacksonConfiguration.getDefaultObjectMapper; +import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import com.google.common.annotations.Beta; import com.sap.ai.sdk.foundationmodels.openai.generated.model.ChatCompletionRequestMessage; @@ -53,4 +54,26 @@ static ObjectMapper getOpenAiObjectMapper() { ChatCompletionsCreate200Response.class, JacksonMixins.DefaultChatCompletionCreate200ResponseMixIn.class); } + + @Nonnull + static T parseJson(@Nonnull final String json, @Nonnull final TypeReference typeReference) + throws IllegalArgumentException { + try { + return getOpenAiObjectMapper().readValue(json, typeReference); + } catch (Exception e) { + throw new IllegalArgumentException( + "Failed to parse JSON string to type " + typeReference.getType(), e); + } + } + + @Nonnull + static T parseJson(@Nonnull final String json, @Nonnull final Class clazz) + throws IllegalArgumentException { + try { + return getOpenAiObjectMapper().readValue(json, clazz); + } catch (Exception e) { + throw new IllegalArgumentException( + "Failed to parse JSON string to class " + clazz.getTypeName(), e); + } + } } diff --git a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OpenAiServiceV2.java b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OpenAiServiceV2.java index b604caf00..edb421383 100644 --- a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OpenAiServiceV2.java +++ b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OpenAiServiceV2.java @@ -3,7 +3,6 @@ import static com.sap.ai.sdk.foundationmodels.openai.OpenAiModel.GPT_4O; import static com.sap.ai.sdk.foundationmodels.openai.OpenAiModel.GPT_4O_MINI; import static com.sap.ai.sdk.foundationmodels.openai.OpenAiModel.TEXT_EMBEDDING_3_SMALL; -import static com.sap.ai.sdk.foundationmodels.openai.generated.model.ChatCompletionTool.TypeEnum.FUNCTION; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.JsonMappingException; @@ -18,11 +17,10 @@ import com.sap.ai.sdk.foundationmodels.openai.OpenAiEmbeddingRequest; import com.sap.ai.sdk.foundationmodels.openai.OpenAiEmbeddingResponse; import com.sap.ai.sdk.foundationmodels.openai.OpenAiFunctionCall; +import com.sap.ai.sdk.foundationmodels.openai.OpenAiFunctionTool; import com.sap.ai.sdk.foundationmodels.openai.OpenAiImageItem; import com.sap.ai.sdk.foundationmodels.openai.OpenAiMessage; import com.sap.ai.sdk.foundationmodels.openai.OpenAiToolCall; -import com.sap.ai.sdk.foundationmodels.openai.generated.model.ChatCompletionTool; -import com.sap.ai.sdk.foundationmodels.openai.generated.model.FunctionObject; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -103,19 +101,17 @@ public OpenAiChatCompletionResponse chatCompletionToolExecution( @Nonnull final String location, @Nonnull final String unit) { // 1. Define the function - final Map schemaMap = generateSchema(WeatherMethod.Request.class); - final var function = - new FunctionObject() - .name("weather") - .description("Get the weather for the given location") - .parameters(schemaMap); - final var tool = new ChatCompletionTool().type(FUNCTION).function(function); + final var openAiTool = + new OpenAiFunctionTool("weather", WeatherMethod.Request.class) + .withDescription("Get the weather for the given location"); final var messages = new ArrayList(); messages.add(OpenAiMessage.user("What's the weather in %s in %s?".formatted(location, unit))); // Assistant will call the function - final var request = new OpenAiChatCompletionRequest(messages).withTools(List.of(tool)); + final var request = + new OpenAiChatCompletionRequest(messages).withOpenAiTools(List.of(openAiTool)); + final OpenAiChatCompletionResponse response = OpenAiClient.forModel(GPT_4O_MINI).chatCompletion(request); From 75fe7dd45b9f6defe69af8214c38046eae5db6db Mon Sep 17 00:00:00 2001 From: Roshin Rajan Panackal Date: Mon, 7 Apr 2025 17:36:50 +0200 Subject: [PATCH 03/36] docs: enhance OpenAiFunctionTool and related classes documentation --- foundation-models/openai/pom.xml | 4 +++ .../openai/OpenAiFunctionCall.java | 9 ++++--- .../openai/OpenAiFunctionTool.java | 25 +++++++++++++++++-- .../foundationmodels/openai/OpenAiTool.java | 5 ++++ .../foundationmodels/openai/OpenAiUtils.java | 20 +++++++++++++++ 5 files changed, 57 insertions(+), 6 deletions(-) diff --git a/foundation-models/openai/pom.xml b/foundation-models/openai/pom.xml index 1edb7ca9e..2ec3b6606 100644 --- a/foundation-models/openai/pom.xml +++ b/foundation-models/openai/pom.xml @@ -77,6 +77,10 @@ com.fasterxml.jackson.core jackson-annotations + + com.fasterxml.jackson.module + jackson-module-jsonSchema + io.vavr vavr diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionCall.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionCall.java index cfdd0de1d..b2524bc0b 100644 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionCall.java +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionCall.java @@ -1,6 +1,5 @@ package com.sap.ai.sdk.foundationmodels.openai; -import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.google.common.annotations.Beta; import java.util.Map; @@ -29,11 +28,12 @@ public class OpenAiFunctionCall implements OpenAiToolCall { /** * Returns the arguments as a {@code Map}. * - * @return the arguments as a map + * @return the parsed arguments * @throws IllegalArgumentException if parsing fails */ + @Nonnull public Map getArgumentsAsMap() throws IllegalArgumentException { - return OpenAiUtils.parseJson(getArguments(), new TypeReference>() {}); + return OpenAiUtils.parseJson(getArguments(), new TypeReference<>() {}); } /** @@ -44,7 +44,8 @@ public Map getArgumentsAsMap() throws IllegalArgumentException { * @return the arguments as an object of the specified class * @throws IllegalArgumentException if parsing fails */ - public T getArgumentsAsObject(Class clazz) throws IllegalArgumentException { + @Nonnull + public T getArgumentsAsObject(@Nonnull final Class clazz) throws IllegalArgumentException { return OpenAiUtils.parseJson(getArguments(), clazz); } } diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionTool.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionTool.java index 4cb1510c5..3b5f7108b 100644 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionTool.java +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionTool.java @@ -18,24 +18,44 @@ import lombok.Value; import lombok.With; +/** + * Represents an OpenAI function tool that can be used to define a function call in an OpenAI Chat + * Completion request. This tool generates a JSON schema based on the provided class representing + * the function's request structure. + * + * @see OpenAI Function + * @since 1.7.0 + */ @Beta @Value @With @AllArgsConstructor(access = AccessLevel.PRIVATE) public class OpenAiFunctionTool implements OpenAiTool { + /** The name of the function. */ @Nonnull String name; + + /** The model class for function request. */ @Nonnull Class clazz; + /** An optional description of the function. */ @Nullable String description; + + /** An optional flag indicating whether the function parameters should be treated strictly. */ @Nullable Boolean strict; + /** + * Constructs an {@code OpenAiFunctionTool} with the specified name and request class. + * + * @param name the name of the function + * @param clazz the model class for the function request + */ public OpenAiFunctionTool(@Nonnull final String name, @Nonnull final Class clazz) { this(name, clazz, null, null); } ChatCompletionTool createChatCompletionTool() { - var objectMapper = new ObjectMapper(); + final var objectMapper = new ObjectMapper(); JsonSchema schema = null; try { schema = new JsonSchemaGenerator(objectMapper).generateSchema(clazz); @@ -43,7 +63,8 @@ ChatCompletionTool createChatCompletionTool() { throw new IllegalArgumentException("Could not generate schema for " + clazz.getTypeName(), e); } - var schemaMap = objectMapper.convertValue(schema, new TypeReference>() {}); + final var schemaMap = + objectMapper.convertValue(schema, new TypeReference>() {}); final var function = new FunctionObject() diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiTool.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiTool.java index 3389a87d2..3c6675854 100644 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiTool.java +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiTool.java @@ -1,3 +1,8 @@ package com.sap.ai.sdk.foundationmodels.openai; +/** + * Represents a tool that can be integrated into an OpenAI Chat Completion request. + * + * @since 1.7.0 + */ public sealed interface OpenAiTool permits OpenAiFunctionTool {} diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiUtils.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiUtils.java index 3a16ba3a5..718f56d2e 100644 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiUtils.java +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiUtils.java @@ -55,6 +55,16 @@ static ObjectMapper getOpenAiObjectMapper() { JacksonMixins.DefaultChatCompletionCreate200ResponseMixIn.class); } + /** + * Parses a JSON string into an object of the specified {@code TypeReference} using the module + * default object mapper. + * + * @param json the JSON string to parse + * @param typeReference the type reference for the target type + * @param the target type + * @return the parsed object + * @throws IllegalArgumentException if parsing fails + */ @Nonnull static T parseJson(@Nonnull final String json, @Nonnull final TypeReference typeReference) throws IllegalArgumentException { @@ -66,6 +76,16 @@ static T parseJson(@Nonnull final String json, @Nonnull final TypeReference< } } + /** + * Parses a JSON string into an object of the specified class using the module default object + * mapper. + * + * @param json the JSON string to parse + * @param clazz the class of the target type + * @param the target type + * @return the parsed object + * @throws IllegalArgumentException if parsing fails + */ @Nonnull static T parseJson(@Nonnull final String json, @Nonnull final Class clazz) throws IllegalArgumentException { From 484c14d942df42ce74109f80c70b894a556fc8fe Mon Sep 17 00:00:00 2001 From: Roshin Rajan Panackal Date: Mon, 7 Apr 2025 17:50:41 +0200 Subject: [PATCH 04/36] docs: add @since 1.7.0 annotations to relevant methods for version tracking --- .../openai/OpenAiChatCompletionRequest.java | 1 + .../foundationmodels/openai/OpenAiFunctionCall.java | 2 ++ .../ai/sdk/foundationmodels/openai/OpenAiUtils.java | 2 ++ .../com/sap/ai/sdk/app/services/OpenAiServiceV2.java | 11 +---------- 4 files changed, 6 insertions(+), 10 deletions(-) diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequest.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequest.java index 27705052c..f4ce709d7 100644 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequest.java +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequest.java @@ -287,6 +287,7 @@ public OpenAiChatCompletionRequest withToolChoice(@Nonnull final OpenAiToolChoic * * @param tools the list of tools to be used * @return a new OpenAiChatCompletionRequest instance with the specified tools + * @since 1.7.0 */ @Nonnull public OpenAiChatCompletionRequest withOpenAiTools(@Nonnull final List tools) { diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionCall.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionCall.java index b2524bc0b..4dd8904f7 100644 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionCall.java +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionCall.java @@ -30,6 +30,7 @@ public class OpenAiFunctionCall implements OpenAiToolCall { * * @return the parsed arguments * @throws IllegalArgumentException if parsing fails + * @since 1.7.0 */ @Nonnull public Map getArgumentsAsMap() throws IllegalArgumentException { @@ -43,6 +44,7 @@ public Map getArgumentsAsMap() throws IllegalArgumentException { * @param the type of the class * @return the arguments as an object of the specified class * @throws IllegalArgumentException if parsing fails + * @since 1.7.0 */ @Nonnull public T getArgumentsAsObject(@Nonnull final Class clazz) throws IllegalArgumentException { diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiUtils.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiUtils.java index 718f56d2e..eca1b9774 100644 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiUtils.java +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiUtils.java @@ -64,6 +64,7 @@ static ObjectMapper getOpenAiObjectMapper() { * @param the target type * @return the parsed object * @throws IllegalArgumentException if parsing fails + * @since 1.7.0 */ @Nonnull static T parseJson(@Nonnull final String json, @Nonnull final TypeReference typeReference) @@ -85,6 +86,7 @@ static T parseJson(@Nonnull final String json, @Nonnull final TypeReference< * @param the target type * @return the parsed object * @throws IllegalArgumentException if parsing fails + * @since 1.7.0 */ @Nonnull static T parseJson(@Nonnull final String json, @Nonnull final Class clazz) diff --git a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OpenAiServiceV2.java b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OpenAiServiceV2.java index edb421383..1f9c322db 100644 --- a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OpenAiServiceV2.java +++ b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OpenAiServiceV2.java @@ -135,16 +135,7 @@ public OpenAiChatCompletionResponse chatCompletionToolExecution( return OpenAiClient.forModel(GPT_4O_MINI).chatCompletion(request.withMessages(messages)); } - @Nonnull - private static Map generateSchema(@Nonnull final Class clazz) { - final var jsonSchemaGenerator = new JsonSchemaGenerator(JACKSON); - try { - final var schema = jsonSchemaGenerator.generateSchema(clazz); - return JACKSON.convertValue(schema, new TypeReference<>() {}); - } catch (JsonMappingException e) { - throw new IllegalArgumentException("Could not generate schema for " + clazz.getName(), e); - } - } + /** * Get the embedding of a text From 58792c63cbcd0192e77b460be93c18b079943e63 Mon Sep 17 00:00:00 2001 From: Roshin Rajan Panackal Date: Mon, 7 Apr 2025 17:51:48 +0200 Subject: [PATCH 05/36] ci --- .../java/com/sap/ai/sdk/app/services/OpenAiServiceV2.java | 6 ------ 1 file changed, 6 deletions(-) diff --git a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OpenAiServiceV2.java b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OpenAiServiceV2.java index 1f9c322db..a66ffc958 100644 --- a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OpenAiServiceV2.java +++ b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OpenAiServiceV2.java @@ -4,10 +4,7 @@ import static com.sap.ai.sdk.foundationmodels.openai.OpenAiModel.GPT_4O_MINI; import static com.sap.ai.sdk.foundationmodels.openai.OpenAiModel.TEXT_EMBEDDING_3_SMALL; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.JsonMappingException; import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.module.jsonSchema.JsonSchemaGenerator; import com.sap.ai.sdk.core.AiCoreService; import com.sap.ai.sdk.foundationmodels.openai.OpenAiAssistantMessage; import com.sap.ai.sdk.foundationmodels.openai.OpenAiChatCompletionDelta; @@ -23,7 +20,6 @@ import com.sap.ai.sdk.foundationmodels.openai.OpenAiToolCall; import java.util.ArrayList; import java.util.List; -import java.util.Map; import java.util.stream.Stream; import javax.annotation.Nonnull; import lombok.extern.slf4j.Slf4j; @@ -135,8 +131,6 @@ public OpenAiChatCompletionResponse chatCompletionToolExecution( return OpenAiClient.forModel(GPT_4O_MINI).chatCompletion(request.withMessages(messages)); } - - /** * Get the embedding of a text * From cd03dac8d150f0e6836ee4dca2c08e3c29a1a47a Mon Sep 17 00:00:00 2001 From: Roshin Rajan Panackal Date: Mon, 7 Apr 2025 18:04:07 +0200 Subject: [PATCH 06/36] fix unintentional javadoc change --- .../foundationmodels/openai/OpenAiChatCompletionRequest.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequest.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequest.java index f4ce709d7..e5517d303 100644 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequest.java +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequest.java @@ -306,8 +306,7 @@ public OpenAiChatCompletionRequest withOpenAiTools(@Nonnull final List Date: Tue, 8 Apr 2025 12:01:20 +0200 Subject: [PATCH 07/36] test: update Javadoc to include exception details and enhance test coverage for OpenAiFunctionCall and OpenAiFunctionTool --- .../openai/OpenAiChatCompletionRequest.java | 1 + .../openai/OpenAiFunctionTool.java | 1 + .../OpenAiChatCompletionRequestTest.java | 43 +++++++++++++++++++ .../openai/OpenAiClientGeneratedTest.java | 5 +++ 4 files changed, 50 insertions(+) diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequest.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequest.java index e5517d303..a0249b382 100644 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequest.java +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequest.java @@ -287,6 +287,7 @@ public OpenAiChatCompletionRequest withToolChoice(@Nonnull final OpenAiToolChoic * * @param tools the list of tools to be used * @return a new OpenAiChatCompletionRequest instance with the specified tools + * @throws IllegalArgumentException if the tool type is not supported * @since 1.7.0 */ @Nonnull diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionTool.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionTool.java index 3b5f7108b..09c9a83c4 100644 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionTool.java +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionTool.java @@ -63,6 +63,7 @@ ChatCompletionTool createChatCompletionTool() { throw new IllegalArgumentException("Could not generate schema for " + clazz.getTypeName(), e); } + schema.setId(null); final var schemaMap = objectMapper.convertValue(schema, new TypeReference>() {}); diff --git a/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequestTest.java b/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequestTest.java index d222ddf05..7bf06ab2b 100644 --- a/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequestTest.java +++ b/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequestTest.java @@ -4,11 +4,13 @@ import com.sap.ai.sdk.foundationmodels.openai.generated.model.ChatCompletionRequestUserMessage; import com.sap.ai.sdk.foundationmodels.openai.generated.model.ChatCompletionRequestUserMessageContent; +import com.sap.ai.sdk.foundationmodels.openai.generated.model.ChatCompletionTool; import com.sap.ai.sdk.foundationmodels.openai.generated.model.ChatCompletionToolChoiceOption; import com.sap.ai.sdk.foundationmodels.openai.generated.model.CreateChatCompletionRequestAllOfStop; import java.math.BigDecimal; import java.util.ArrayList; import java.util.List; +import java.util.Map; import org.junit.jupiter.api.Test; class OpenAiChatCompletionRequestTest { @@ -114,4 +116,45 @@ void messageListExternallyUnmodifiable() { .as("Modifying the original list should not affect the messages in the request object.") .hasSize(1); } + + @Test + void withOpenAiTools() { + record DummyRequest(String param1, int param2) {} + + var request = + new OpenAiChatCompletionRequest(OpenAiMessage.user("Hello, world")) + .withOpenAiTools( + List.of( + new OpenAiFunctionTool("toolA", DummyRequest.class) + .withDescription("descA") + .withStrict(true), + new OpenAiFunctionTool("toolB", String.class) + .withDescription("descB") + .withStrict(false))); + + var lowLevelRequest = request.createCreateChatCompletionRequest(); + assertThat(lowLevelRequest.getTools()).hasSize(2); + + var toolA = lowLevelRequest.getTools().get(0); + assertThat(toolA).isInstanceOf(ChatCompletionTool.class); + assertThat(toolA.getType()).isEqualTo(ChatCompletionTool.TypeEnum.FUNCTION); + assertThat(toolA.getFunction().getName()).isEqualTo("toolA"); + assertThat(toolA.getFunction().getDescription()).isEqualTo("descA"); + assertThat(toolA.getFunction().isStrict()).isTrue(); + assertThat(toolA.getFunction().getParameters()) + .isEqualTo( + Map.of( + "properties", + Map.of("param1", Map.of("type", "string"), "param2", Map.of("type", "integer")), + "type", + "object")); + + var toolB = lowLevelRequest.getTools().get(1); + assertThat(toolB).isInstanceOf(ChatCompletionTool.class); + assertThat(toolB.getType()).isEqualTo(ChatCompletionTool.TypeEnum.FUNCTION); + assertThat(toolB.getFunction().getName()).isEqualTo("toolB"); + assertThat(toolB.getFunction().getDescription()).isEqualTo("descB"); + assertThat(toolB.getFunction().isStrict()).isFalse(); + assertThat(toolB.getFunction().getParameters()).isEqualTo(Map.of("type", "string")); + } } diff --git a/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiClientGeneratedTest.java b/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiClientGeneratedTest.java index 527ad9f36..4c87e3786 100644 --- a/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiClientGeneratedTest.java +++ b/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiClientGeneratedTest.java @@ -605,5 +605,10 @@ void chatCompletionToolCallGetMessage() { assertThat(toolCall.getId()).isEqualTo("call_CUYGJf2j7FRWJMHT3PN3aGxK"); assertThat(toolCall.getName()).isEqualTo("fibonacci"); assertThat(toolCall.getArguments()).isEqualTo("{\"N\":12}"); + assertThat(toolCall.getArgumentsAsMap()).isEqualTo(Map.of("N", 12)); + + record DummyRequest(int N) {} + assertThat(toolCall.getArgumentsAsObject(DummyRequest.class)).isInstanceOf(DummyRequest.class); + assertThat(toolCall.getArgumentsAsObject(DummyRequest.class).N()).isEqualTo(12); } } From 03453a46629501178b35f0ec45d72c5ac5e83232 Mon Sep 17 00:00:00 2001 From: Roshin Rajan Panackal Date: Tue, 8 Apr 2025 12:14:21 +0200 Subject: [PATCH 08/36] refactor: enhance JSON parsing methods in OpenAiFunctionCall and remove redundant methods from OpenAiUtils --- .../openai/OpenAiFunctionCall.java | 25 ++++++++--- .../foundationmodels/openai/OpenAiUtils.java | 45 ------------------- 2 files changed, 19 insertions(+), 51 deletions(-) diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionCall.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionCall.java index 4dd8904f7..5b8b31d1f 100644 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionCall.java +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionCall.java @@ -1,5 +1,7 @@ package com.sap.ai.sdk.foundationmodels.openai; +import static com.sap.ai.sdk.foundationmodels.openai.OpenAiUtils.getOpenAiObjectMapper; + import com.fasterxml.jackson.core.type.TypeReference; import com.google.common.annotations.Beta; import java.util.Map; @@ -26,28 +28,39 @@ public class OpenAiFunctionCall implements OpenAiToolCall { @Nonnull String arguments; /** - * Returns the arguments as a {@code Map}. + * Parses the arguments, encoded as a JSON string, into a {@code Map}. * - * @return the parsed arguments + * @return a map of the arguments * @throws IllegalArgumentException if parsing fails * @since 1.7.0 */ @Nonnull public Map getArgumentsAsMap() throws IllegalArgumentException { - return OpenAiUtils.parseJson(getArguments(), new TypeReference<>() {}); + final var typeReference = new TypeReference>() {}; + try { + return getOpenAiObjectMapper().readValue(getArguments(), typeReference); + } catch (Exception e) { + throw new IllegalArgumentException( + "Failed to parse JSON string to type " + typeReference.getType(), e); + } } /** - * Returns the arguments as an object of the specified class. + * Parses the arguments, encoded as a JSON string, into an object of the specified type. * * @param clazz the class to convert the arguments to * @param the type of the class - * @return the arguments as an object of the specified class + * @return the parsed arguments as an object * @throws IllegalArgumentException if parsing fails * @since 1.7.0 */ @Nonnull public T getArgumentsAsObject(@Nonnull final Class clazz) throws IllegalArgumentException { - return OpenAiUtils.parseJson(getArguments(), clazz); + try { + return getOpenAiObjectMapper().readValue(getArguments(), clazz); + } catch (Exception e) { + throw new IllegalArgumentException( + "Failed to parse JSON string to class " + clazz.getTypeName(), e); + } } } diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiUtils.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiUtils.java index eca1b9774..0b12565b9 100644 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiUtils.java +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiUtils.java @@ -2,7 +2,6 @@ import static com.sap.ai.sdk.core.JacksonConfiguration.getDefaultObjectMapper; -import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import com.google.common.annotations.Beta; import com.sap.ai.sdk.foundationmodels.openai.generated.model.ChatCompletionRequestMessage; @@ -54,48 +53,4 @@ static ObjectMapper getOpenAiObjectMapper() { ChatCompletionsCreate200Response.class, JacksonMixins.DefaultChatCompletionCreate200ResponseMixIn.class); } - - /** - * Parses a JSON string into an object of the specified {@code TypeReference} using the module - * default object mapper. - * - * @param json the JSON string to parse - * @param typeReference the type reference for the target type - * @param the target type - * @return the parsed object - * @throws IllegalArgumentException if parsing fails - * @since 1.7.0 - */ - @Nonnull - static T parseJson(@Nonnull final String json, @Nonnull final TypeReference typeReference) - throws IllegalArgumentException { - try { - return getOpenAiObjectMapper().readValue(json, typeReference); - } catch (Exception e) { - throw new IllegalArgumentException( - "Failed to parse JSON string to type " + typeReference.getType(), e); - } - } - - /** - * Parses a JSON string into an object of the specified class using the module default object - * mapper. - * - * @param json the JSON string to parse - * @param clazz the class of the target type - * @param the target type - * @return the parsed object - * @throws IllegalArgumentException if parsing fails - * @since 1.7.0 - */ - @Nonnull - static T parseJson(@Nonnull final String json, @Nonnull final Class clazz) - throws IllegalArgumentException { - try { - return getOpenAiObjectMapper().readValue(json, clazz); - } catch (Exception e) { - throw new IllegalArgumentException( - "Failed to parse JSON string to class " + clazz.getTypeName(), e); - } - } } From 36ed5bdee49970014ef79a61b2552699137373cc Mon Sep 17 00:00:00 2001 From: Roshin Rajan Panackal Date: Tue, 8 Apr 2025 12:46:09 +0200 Subject: [PATCH 09/36] refactor: simplify JSON parsing in OpenAiFunctionCall and add unit tests for argument methods --- .../openai/OpenAiFunctionCall.java | 12 ++--- .../openai/OpenAiClientGeneratedTest.java | 5 -- .../openai/OpenAiToolCallTest.java | 49 +++++++++++++++++++ 3 files changed, 52 insertions(+), 14 deletions(-) create mode 100644 foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiToolCallTest.java diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionCall.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionCall.java index 5b8b31d1f..5e9833d4c 100644 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionCall.java +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionCall.java @@ -2,7 +2,7 @@ import static com.sap.ai.sdk.foundationmodels.openai.OpenAiUtils.getOpenAiObjectMapper; -import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.core.JsonProcessingException; import com.google.common.annotations.Beta; import java.util.Map; import javax.annotation.Nonnull; @@ -36,13 +36,7 @@ public class OpenAiFunctionCall implements OpenAiToolCall { */ @Nonnull public Map getArgumentsAsMap() throws IllegalArgumentException { - final var typeReference = new TypeReference>() {}; - try { - return getOpenAiObjectMapper().readValue(getArguments(), typeReference); - } catch (Exception e) { - throw new IllegalArgumentException( - "Failed to parse JSON string to type " + typeReference.getType(), e); - } + return getArgumentsAsObject(Map.class); } /** @@ -58,7 +52,7 @@ public Map getArgumentsAsMap() throws IllegalArgumentException { public T getArgumentsAsObject(@Nonnull final Class clazz) throws IllegalArgumentException { try { return getOpenAiObjectMapper().readValue(getArguments(), clazz); - } catch (Exception e) { + } catch (JsonProcessingException e) { throw new IllegalArgumentException( "Failed to parse JSON string to class " + clazz.getTypeName(), e); } diff --git a/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiClientGeneratedTest.java b/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiClientGeneratedTest.java index 4c87e3786..527ad9f36 100644 --- a/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiClientGeneratedTest.java +++ b/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiClientGeneratedTest.java @@ -605,10 +605,5 @@ void chatCompletionToolCallGetMessage() { assertThat(toolCall.getId()).isEqualTo("call_CUYGJf2j7FRWJMHT3PN3aGxK"); assertThat(toolCall.getName()).isEqualTo("fibonacci"); assertThat(toolCall.getArguments()).isEqualTo("{\"N\":12}"); - assertThat(toolCall.getArgumentsAsMap()).isEqualTo(Map.of("N", 12)); - - record DummyRequest(int N) {} - assertThat(toolCall.getArgumentsAsObject(DummyRequest.class)).isInstanceOf(DummyRequest.class); - assertThat(toolCall.getArgumentsAsObject(DummyRequest.class).N()).isEqualTo(12); } } diff --git a/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiToolCallTest.java b/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiToolCallTest.java new file mode 100644 index 000000000..e9a775acb --- /dev/null +++ b/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiToolCallTest.java @@ -0,0 +1,49 @@ +package com.sap.ai.sdk.foundationmodels.openai; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Test; + +class OpenAiToolCallTest { + private static final OpenAiFunctionCall VALID_FUNCTION_CALL = + new OpenAiFunctionCall("1", "functionName", "{\"key\":\"value\"}"); + private static final OpenAiFunctionCall INVALID_FUNCTION_CALL = + new OpenAiFunctionCall("1", "functionName", "{invalid-json}"); + + record DummyRequest(String key) {} + + @Test + void getArgumentsAsMapParsesValidJson() { + var result = VALID_FUNCTION_CALL.getArgumentsAsMap(); + assertThat(result).containsEntry("key", "value"); + } + + @Test + void getArgumentsAsMapThrowsOnInvalidJson() { + assertThatThrownBy(INVALID_FUNCTION_CALL::getArgumentsAsMap) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Failed to parse JSON string"); + } + + @Test + void getArgumentsAsObjectParsesValidJson() { + var result = VALID_FUNCTION_CALL.getArgumentsAsObject(DummyRequest.class); + assertThat(result).isInstanceOf(DummyRequest.class); + assertThat(result.key()).isEqualTo("value"); + } + + @Test + void getArgumentsAsObjectThrowsOnInvalidJson() { + assertThatThrownBy(() -> INVALID_FUNCTION_CALL.getArgumentsAsObject(DummyRequest.class)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Failed to parse JSON string"); + } + + @Test + void getArgumentsAsObjectThrowsOnTypeMismatch() { + assertThatThrownBy(() -> VALID_FUNCTION_CALL.getArgumentsAsObject(Integer.class)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Failed to parse JSON string"); + } +} From 33f2ca896de12134264a0dda4f2ecf8280f3caae Mon Sep 17 00:00:00 2001 From: Roshin Rajan Panackal Date: Tue, 8 Apr 2025 14:51:02 +0200 Subject: [PATCH 10/36] refactor: update OpenAiFunctionTool to improve argument parsing and enhance type safety - OpenAiFunctionTool getter package private - introduce getArgumentsAsObject(OpenAiFunctionTool) --- docs/release_notes.md | 2 +- .../openai/OpenAiFunctionCall.java | 29 +++++++++++++---- .../openai/OpenAiFunctionTool.java | 18 ++++++----- .../openai/OpenAiToolCallTest.java | 14 +++------ .../ai/sdk/app/services/OpenAiServiceV2.java | 31 +++++++------------ 5 files changed, 51 insertions(+), 43 deletions(-) diff --git a/docs/release_notes.md b/docs/release_notes.md index 5745d6681..490694854 100644 --- a/docs/release_notes.md +++ b/docs/release_notes.md @@ -12,7 +12,7 @@ ### ✨ New Functionality -- +- [OpenAI] [Add convenience for tool definition and parsing function calls](https://sap.github.io/ai-sdk/docs/java/foundation-models/openai/chat-completion#executing-tool-calls) ### 📈 Improvements diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionCall.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionCall.java index 5e9833d4c..d164557a7 100644 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionCall.java +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionCall.java @@ -3,7 +3,9 @@ import static com.sap.ai.sdk.foundationmodels.openai.OpenAiUtils.getOpenAiObjectMapper; import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; import com.google.common.annotations.Beta; +import java.lang.reflect.Type; import java.util.Map; import javax.annotation.Nonnull; import lombok.AllArgsConstructor; @@ -36,25 +38,40 @@ public class OpenAiFunctionCall implements OpenAiToolCall { */ @Nonnull public Map getArgumentsAsMap() throws IllegalArgumentException { - return getArgumentsAsObject(Map.class); + return parseArguments(new TypeReference<>() {}); } /** - * Parses the arguments, encoded as a JSON string, into an object of the specified type. + * Parses the arguments, encoded as a JSON string, into an object of type expected by a function + * tool. * - * @param clazz the class to convert the arguments to + * @param tool the function tool the arguments are for * @param the type of the class * @return the parsed arguments as an object * @throws IllegalArgumentException if parsing fails * @since 1.7.0 */ @Nonnull - public T getArgumentsAsObject(@Nonnull final Class clazz) throws IllegalArgumentException { + public T getArgumentsAsObject(@Nonnull final OpenAiFunctionTool tool) + throws IllegalArgumentException { + final var typeRef = + new TypeReference() { + @Override + public Type getType() { + return tool.getRequestModel(); + } + }; + return parseArguments(typeRef); + } + + @Nonnull + private T parseArguments(@Nonnull final TypeReference typeReference) + throws IllegalArgumentException { try { - return getOpenAiObjectMapper().readValue(getArguments(), clazz); + return getOpenAiObjectMapper().readValue(getArguments(), typeReference); } catch (JsonProcessingException e) { throw new IllegalArgumentException( - "Failed to parse JSON string to class " + clazz.getTypeName(), e); + "Failed to parse JSON string to class " + typeReference.getType(), e); } } } diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionTool.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionTool.java index 09c9a83c4..a108d5e9b 100644 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionTool.java +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionTool.java @@ -15,6 +15,7 @@ import javax.annotation.Nullable; import lombok.AccessLevel; import lombok.AllArgsConstructor; +import lombok.Getter; import lombok.Value; import lombok.With; @@ -29,6 +30,7 @@ @Beta @Value @With +@Getter(AccessLevel.PACKAGE) @AllArgsConstructor(access = AccessLevel.PRIVATE) public class OpenAiFunctionTool implements OpenAiTool { @@ -36,7 +38,7 @@ public class OpenAiFunctionTool implements OpenAiTool { @Nonnull String name; /** The model class for function request. */ - @Nonnull Class clazz; + @Nonnull Class requestModel; /** An optional description of the function. */ @Nullable String description; @@ -45,22 +47,24 @@ public class OpenAiFunctionTool implements OpenAiTool { @Nullable Boolean strict; /** - * Constructs an {@code OpenAiFunctionTool} with the specified name and request class. + * Constructs an {@code OpenAiFunctionTool} with the specified name and a model class that + * captures the request to the function. * * @param name the name of the function - * @param clazz the model class for the function request + * @param requestModel the model class for the function request */ - public OpenAiFunctionTool(@Nonnull final String name, @Nonnull final Class clazz) { - this(name, clazz, null, null); + public OpenAiFunctionTool(@Nonnull final String name, @Nonnull final Class requestModel) { + this(name, requestModel, null, null); } ChatCompletionTool createChatCompletionTool() { final var objectMapper = new ObjectMapper(); JsonSchema schema = null; try { - schema = new JsonSchemaGenerator(objectMapper).generateSchema(clazz); + schema = new JsonSchemaGenerator(objectMapper).generateSchema(requestModel); } catch (JsonMappingException e) { - throw new IllegalArgumentException("Could not generate schema for " + clazz.getTypeName(), e); + throw new IllegalArgumentException( + "Could not generate schema for " + requestModel.getTypeName(), e); } schema.setId(null); diff --git a/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiToolCallTest.java b/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiToolCallTest.java index e9a775acb..3f1f5823b 100644 --- a/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiToolCallTest.java +++ b/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiToolCallTest.java @@ -11,6 +11,9 @@ class OpenAiToolCallTest { private static final OpenAiFunctionCall INVALID_FUNCTION_CALL = new OpenAiFunctionCall("1", "functionName", "{invalid-json}"); + private static final OpenAiFunctionTool FUNCTION_TOOL = + new OpenAiFunctionTool("functionName", DummyRequest.class); + record DummyRequest(String key) {} @Test @@ -28,21 +31,14 @@ void getArgumentsAsMapThrowsOnInvalidJson() { @Test void getArgumentsAsObjectParsesValidJson() { - var result = VALID_FUNCTION_CALL.getArgumentsAsObject(DummyRequest.class); + var result = (DummyRequest) VALID_FUNCTION_CALL.getArgumentsAsObject(FUNCTION_TOOL); assertThat(result).isInstanceOf(DummyRequest.class); assertThat(result.key()).isEqualTo("value"); } @Test void getArgumentsAsObjectThrowsOnInvalidJson() { - assertThatThrownBy(() -> INVALID_FUNCTION_CALL.getArgumentsAsObject(DummyRequest.class)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("Failed to parse JSON string"); - } - - @Test - void getArgumentsAsObjectThrowsOnTypeMismatch() { - assertThatThrownBy(() -> VALID_FUNCTION_CALL.getArgumentsAsObject(Integer.class)) + assertThatThrownBy(() -> INVALID_FUNCTION_CALL.getArgumentsAsObject(FUNCTION_TOOL)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("Failed to parse JSON string"); } diff --git a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OpenAiServiceV2.java b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OpenAiServiceV2.java index a66ffc958..160420825 100644 --- a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OpenAiServiceV2.java +++ b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OpenAiServiceV2.java @@ -4,7 +4,6 @@ import static com.sap.ai.sdk.foundationmodels.openai.OpenAiModel.GPT_4O_MINI; import static com.sap.ai.sdk.foundationmodels.openai.OpenAiModel.TEXT_EMBEDDING_3_SMALL; -import com.fasterxml.jackson.databind.ObjectMapper; import com.sap.ai.sdk.core.AiCoreService; import com.sap.ai.sdk.foundationmodels.openai.OpenAiAssistantMessage; import com.sap.ai.sdk.foundationmodels.openai.OpenAiChatCompletionDelta; @@ -29,8 +28,6 @@ @Service @Slf4j public class OpenAiServiceV2 { - private static final ObjectMapper JACKSON = new ObjectMapper(); - /** * Chat request to OpenAI * @@ -95,39 +92,33 @@ public OpenAiChatCompletionResponse chatCompletionImage(@Nonnull final String li @Nonnull public OpenAiChatCompletionResponse chatCompletionToolExecution( @Nonnull final String location, @Nonnull final String unit) { + final var messages = new ArrayList(); + messages.add(OpenAiMessage.user("What's the weather in %s in %s?".formatted(location, unit))); // 1. Define the function - final var openAiTool = + final var weatherFunction = new OpenAiFunctionTool("weather", WeatherMethod.Request.class) .withDescription("Get the weather for the given location"); - final var messages = new ArrayList(); - messages.add(OpenAiMessage.user("What's the weather in %s in %s?".formatted(location, unit))); - - // Assistant will call the function + // 2. Assistant calls the function final var request = - new OpenAiChatCompletionRequest(messages).withOpenAiTools(List.of(openAiTool)); - + new OpenAiChatCompletionRequest(messages).withOpenAiTools(List.of(weatherFunction)); final OpenAiChatCompletionResponse response = OpenAiClient.forModel(GPT_4O_MINI).chatCompletion(request); - - // 2. Optionally, execute the function. final OpenAiAssistantMessage assistantMessage = response.getMessage(); - messages.add(assistantMessage); + // 3. Execute the function final OpenAiToolCall toolCall = assistantMessage.toolCalls().get(0); if (!(toolCall instanceof OpenAiFunctionCall functionCall)) { throw new IllegalArgumentException( "Expected a function call, but got: %s".formatted(assistantMessage)); } + final WeatherMethod.Request arguments = functionCall.getArgumentsAsObject(weatherFunction); + final WeatherMethod.Response currentWeather = WeatherMethod.getCurrentWeather(arguments); - final WeatherMethod.Request arguments = - functionCall.getArgumentsAsObject(WeatherMethod.Request.class); - final WeatherMethod.Response weatherMethod = WeatherMethod.getCurrentWeather(arguments); - - messages.add(OpenAiMessage.tool(weatherMethod.toString(), functionCall.getId())); - - // Send back the results, and the model will incorporate them into its final response. + // 4. Send back the results, and the model will incorporate them into its final response. + messages.add(assistantMessage); + messages.add(OpenAiMessage.tool(currentWeather.toString(), functionCall.getId())); return OpenAiClient.forModel(GPT_4O_MINI).chatCompletion(request.withMessages(messages)); } From 7ce6aef27ae2b16767a3db3910e301e6daf04024 Mon Sep 17 00:00:00 2001 From: Roshin Rajan Panackal Date: Wed, 9 Apr 2025 14:00:59 +0200 Subject: [PATCH 11/36] part done --- .../openai/OpenAiChatCompletionResponse.java | 19 +++++++++++++++++++ .../openai/OpenAiFunctionCall.java | 2 +- .../openai/OpenAiFunctionTool.java | 17 ++++++++++------- .../ai/sdk/app/services/OpenAiServiceV2.java | 17 +++++------------ 4 files changed, 35 insertions(+), 20 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 c1d0bb772..9f92c87f3 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 @@ -8,6 +8,8 @@ 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.ArrayList; import java.util.List; import java.util.Objects; import javax.annotation.Nonnull; @@ -96,4 +98,21 @@ public OpenAiAssistantMessage getMessage() { return new OpenAiAssistantMessage(new OpenAiMessageContent(contentItems), openAiToolCalls); } + + public List executeTools() { + var toolMessages = new ArrayList(); + for (var toolcall : getMessage().toolCalls()) { + if (toolcall instanceof OpenAiFunctionCall functionCall) { + if (functionCall.getName() == "weather") { + final WeatherMethod.Request arguments = + functionCall.getArgumentsAsObject(weatherFunction); + final WeatherMethod.Response currentWeather = WeatherMethod.getCurrentWeather(arguments); + toolMessages.add(currentWeather.toString(), functionCall.getId()); + } + } else { + throw new IllegalArgumentException( + "Expected a function call, but got: %s".formatted(assistantMessage)); + } + } + } } diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionCall.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionCall.java index d164557a7..4e3924154 100644 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionCall.java +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionCall.java @@ -58,7 +58,7 @@ public T getArgumentsAsObject(@Nonnull final OpenAiFunctionTool tool) new TypeReference() { @Override public Type getType() { - return tool.getRequestModel(); + return tool.getFunction(); } }; return parseArguments(typeRef); diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionTool.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionTool.java index a108d5e9b..c9f39c722 100644 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionTool.java +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionTool.java @@ -11,6 +11,7 @@ import com.sap.ai.sdk.foundationmodels.openai.generated.model.ChatCompletionTool; import com.sap.ai.sdk.foundationmodels.openai.generated.model.FunctionObject; import java.util.Map; +import java.util.function.Function; import javax.annotation.Nonnull; import javax.annotation.Nullable; import lombok.AccessLevel; @@ -32,13 +33,15 @@ @With @Getter(AccessLevel.PACKAGE) @AllArgsConstructor(access = AccessLevel.PRIVATE) -public class OpenAiFunctionTool implements OpenAiTool { +public class OpenAiFunctionTool implements OpenAiTool { /** The name of the function. */ @Nonnull String name; /** The model class for function request. */ - @Nonnull Class requestModel; + @Nonnull Function function; + + /** The model class for function response. */ /** An optional description of the function. */ @Nullable String description; @@ -51,20 +54,20 @@ public class OpenAiFunctionTool implements OpenAiTool { * captures the request to the function. * * @param name the name of the function - * @param requestModel the model class for the function request + * @param function the model class for function request */ - public OpenAiFunctionTool(@Nonnull final String name, @Nonnull final Class requestModel) { - this(name, requestModel, null, null); + public OpenAiFunctionTool(@Nonnull final String name, @Nonnull final Function function) { + this(name, function, null, null); } ChatCompletionTool createChatCompletionTool() { final var objectMapper = new ObjectMapper(); JsonSchema schema = null; try { - schema = new JsonSchemaGenerator(objectMapper).generateSchema(requestModel); + schema = new JsonSchemaGenerator(objectMapper).generateSchema(Class.class); } catch (JsonMappingException e) { throw new IllegalArgumentException( - "Could not generate schema for " + requestModel.getTypeName(), e); + "Could not generate schema for " + function.getTypeName(), e); } schema.setId(null); diff --git a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OpenAiServiceV2.java b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OpenAiServiceV2.java index 160420825..9e74d0f81 100644 --- a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OpenAiServiceV2.java +++ b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OpenAiServiceV2.java @@ -12,11 +12,9 @@ import com.sap.ai.sdk.foundationmodels.openai.OpenAiClient; import com.sap.ai.sdk.foundationmodels.openai.OpenAiEmbeddingRequest; import com.sap.ai.sdk.foundationmodels.openai.OpenAiEmbeddingResponse; -import com.sap.ai.sdk.foundationmodels.openai.OpenAiFunctionCall; import com.sap.ai.sdk.foundationmodels.openai.OpenAiFunctionTool; import com.sap.ai.sdk.foundationmodels.openai.OpenAiImageItem; import com.sap.ai.sdk.foundationmodels.openai.OpenAiMessage; -import com.sap.ai.sdk.foundationmodels.openai.OpenAiToolCall; import java.util.ArrayList; import java.util.List; import java.util.stream.Stream; @@ -97,7 +95,8 @@ public OpenAiChatCompletionResponse chatCompletionToolExecution( // 1. Define the function final var weatherFunction = - new OpenAiFunctionTool("weather", WeatherMethod.Request.class) + new OpenAiFunctionTool( + "weather", WeatherMethod.Request.class, WeatherMethod.Response.class) .withDescription("Get the weather for the given location"); // 2. Assistant calls the function @@ -106,16 +105,10 @@ public OpenAiChatCompletionResponse chatCompletionToolExecution( final OpenAiChatCompletionResponse response = OpenAiClient.forModel(GPT_4O_MINI).chatCompletion(request); final OpenAiAssistantMessage assistantMessage = response.getMessage(); - + // 3. Execute the function - final OpenAiToolCall toolCall = assistantMessage.toolCalls().get(0); - if (!(toolCall instanceof OpenAiFunctionCall functionCall)) { - throw new IllegalArgumentException( - "Expected a function call, but got: %s".formatted(assistantMessage)); - } - final WeatherMethod.Request arguments = functionCall.getArgumentsAsObject(weatherFunction); - final WeatherMethod.Response currentWeather = WeatherMethod.getCurrentWeather(arguments); - + + // 4. Send back the results, and the model will incorporate them into its final response. messages.add(assistantMessage); messages.add(OpenAiMessage.tool(currentWeather.toString(), functionCall.getId())); From 57ba2a18b518d6df6ba4fe370216ba81b05a1652 Mon Sep 17 00:00:00 2001 From: I538344 Date: Wed, 9 Apr 2025 15:02:07 +0200 Subject: [PATCH 12/36] finito --- .../openai/OpenAiChatCompletionRequest.java | 7 +-- .../openai/OpenAiChatCompletionResponse.java | 46 ++++++++++++------- .../foundationmodels/openai/OpenAiClient.java | 2 +- .../openai/OpenAiFunctionCall.java | 38 +++------------ .../openai/OpenAiFunctionTool.java | 11 +++-- .../ai/sdk/app/services/OpenAiServiceV2.java | 19 ++++---- 6 files changed, 56 insertions(+), 67 deletions(-) diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequest.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequest.java index a0249b382..99e5facb5 100644 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequest.java +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequest.java @@ -123,7 +123,8 @@ public class OpenAiChatCompletionRequest { @Nullable CreateChatCompletionRequestAllOfResponseFormat responseFormat; /** List of tools that the model may invoke during the completion. */ - @Nullable List tools; + @Getter(value = AccessLevel.PACKAGE) + @Nullable List> tools; /** Option to control which tool is invoked by the model. */ @With(AccessLevel.PRIVATE) @@ -297,7 +298,7 @@ public OpenAiChatCompletionRequest withOpenAiTools(@Nonnull final List { if (tool instanceof OpenAiFunctionTool) { - return ((OpenAiFunctionTool) tool).createChatCompletionTool(); + return ((OpenAiFunctionTool) tool).createChatCompletionTool(); } else { throw new IllegalArgumentException( "Unsupported tool type: " + tool.getClass().getName()); @@ -336,7 +337,7 @@ CreateChatCompletionRequest createCreateChatCompletionRequest() { request.seed(this.seed); request.streamOptions(this.streamOptions); request.responseFormat(this.responseFormat); - request.tools(this.tools); + request.tools(this.tools.createChatCompletionTool()); request.toolChoice(this.toolChoice); request.functionCall(null); request.functions(null); 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 9f92c87f3..e5ee3b46a 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 @@ -4,18 +4,20 @@ import static lombok.AccessLevel.NONE; import static lombok.AccessLevel.PACKAGE; +import com.fasterxml.jackson.core.type.TypeReference; import com.google.common.annotations.Beta; +import com.sap.ai.sdk.foundationmodels.openai.generated.model.ChatCompletionTool; 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.ArrayList; import java.util.List; import java.util.Objects; import javax.annotation.Nonnull; +import javax.annotation.Nullable; import lombok.RequiredArgsConstructor; import lombok.Setter; import lombok.Value; +import lombok.val; /** * Represents the output of an OpenAI chat completion. * @@ -28,7 +30,9 @@ @Setter(value = NONE) public class OpenAiChatCompletionResponse { /** The original response from the OpenAI API. */ - @Nonnull final CreateChatCompletionResponse originalResponse; + @Nonnull CreateChatCompletionResponse originalResponse; + + @Nullable List functions; /** * Gets the token usage from the original response. @@ -99,20 +103,30 @@ public OpenAiAssistantMessage getMessage() { return new OpenAiAssistantMessage(new OpenAiMessageContent(contentItems), openAiToolCalls); } - public List executeTools() { - var toolMessages = new ArrayList(); - for (var toolcall : getMessage().toolCalls()) { - if (toolcall instanceof OpenAiFunctionCall functionCall) { - if (functionCall.getName() == "weather") { - final WeatherMethod.Request arguments = - functionCall.getArgumentsAsObject(weatherFunction); - final WeatherMethod.Response currentWeather = WeatherMethod.getCurrentWeather(arguments); - toolMessages.add(currentWeather.toString(), functionCall.getId()); - } - } else { - throw new IllegalArgumentException( - "Expected a function call, but got: %s".formatted(assistantMessage)); + public List executeTools( List tools ) { + return getMessage().toolCalls().stream() + .filter(toolCall -> toolCall instanceof OpenAiFunctionCall) + .map(toolCall -> (OpenAiFunctionCall) toolCall) + .map( + functionCall -> { + OpenAiFunctionTool request = findFunction(tools, functionCall.getName()); + T arguments = functionCall.parseArguments(new TypeReference() {}); + R response = request.call(arguments); + return OpenAiMessage.tool(response, functionCall.getId()); + }) + .toList(); + } + + @Nullable + private OpenAiFunctionTool findFunction(List tools, String name) { + if (functions == null) { + return null; + } + for (OpenAiTool tool : tools) { + if (tool instanceof OpenAiFunctionTool function && function.getName().equals(name)) { + return (OpenAiFunctionTool) function; } } + return null; } } 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 7cfcadb35..80d5a1a51 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 @@ -159,7 +159,7 @@ public OpenAiChatCompletionResponse chatCompletion( @Nonnull final OpenAiChatCompletionRequest request) throws OpenAiClientException { warnIfUnsupportedUsage(); return new OpenAiChatCompletionResponse( - chatCompletion(request.createCreateChatCompletionRequest())); + chatCompletion(request.createCreateChatCompletionRequest()), request.getTools()); } /** diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionCall.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionCall.java index 4e3924154..82110cfcc 100644 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionCall.java +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionCall.java @@ -5,8 +5,6 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.google.common.annotations.Beta; -import java.lang.reflect.Type; -import java.util.Map; import javax.annotation.Nonnull; import lombok.AllArgsConstructor; import lombok.Value; @@ -29,49 +27,25 @@ public class OpenAiFunctionCall implements OpenAiToolCall { /** The arguments for the function call, encoded as a JSON string. */ @Nonnull String arguments; - /** - * Parses the arguments, encoded as a JSON string, into a {@code Map}. - * - * @return a map of the arguments - * @throws IllegalArgumentException if parsing fails - * @since 1.7.0 - */ - @Nonnull - public Map getArgumentsAsMap() throws IllegalArgumentException { - return parseArguments(new TypeReference<>() {}); - } - /** * Parses the arguments, encoded as a JSON string, into an object of type expected by a function * tool. * - * @param tool the function tool the arguments are for - * @param the type of the class + * @param request the type of the class * @return the parsed arguments as an object * @throws IllegalArgumentException if parsing fails * @since 1.7.0 */ @Nonnull - public T getArgumentsAsObject(@Nonnull final OpenAiFunctionTool tool) - throws IllegalArgumentException { - final var typeRef = - new TypeReference() { - @Override - public Type getType() { - return tool.getFunction(); - } - }; - return parseArguments(typeRef); - } - - @Nonnull - private T parseArguments(@Nonnull final TypeReference typeReference) + T parseArguments(@Nonnull final TypeReference request) throws IllegalArgumentException { try { - return getOpenAiObjectMapper().readValue(getArguments(), typeReference); + return getOpenAiObjectMapper().readValue(arguments, request); } catch (JsonProcessingException e) { throw new IllegalArgumentException( - "Failed to parse JSON string to class " + typeReference.getType(), e); + "Failed to parse JSON string to class " + request, e); } } + + } diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionTool.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionTool.java index c9f39c722..8f0e2e117 100644 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionTool.java +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionTool.java @@ -41,8 +41,6 @@ public class OpenAiFunctionTool implements OpenAiTool { /** The model class for function request. */ @Nonnull Function function; - /** The model class for function response. */ - /** An optional description of the function. */ @Nullable String description; @@ -60,14 +58,19 @@ public OpenAiFunctionTool(@Nonnull final String name, @Nonnull final Function.class); + schema = new JsonSchemaGenerator(objectMapper).generateSchema(new TypeReference() {}.getClass()); } catch (JsonMappingException e) { throw new IllegalArgumentException( - "Could not generate schema for " + function.getTypeName(), e); + "Could not generate schema for " + name, e); } schema.setId(null); diff --git a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OpenAiServiceV2.java b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OpenAiServiceV2.java index 40423977d..17ec6a18b 100644 --- a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OpenAiServiceV2.java +++ b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OpenAiServiceV2.java @@ -15,6 +15,7 @@ import com.sap.ai.sdk.foundationmodels.openai.OpenAiFunctionTool; import com.sap.ai.sdk.foundationmodels.openai.OpenAiImageItem; import com.sap.ai.sdk.foundationmodels.openai.OpenAiMessage; +import com.sap.ai.sdk.foundationmodels.openai.OpenAiTool; import java.util.ArrayList; import java.util.List; import java.util.stream.Stream; @@ -96,24 +97,20 @@ public OpenAiChatCompletionResponse chatCompletionToolExecution( messages.add(OpenAiMessage.user("What's the weather in %s in %s?".formatted(location, unit))); // 1. Define the function - final var weatherFunction = - new OpenAiFunctionTool( - "weather", WeatherMethod.Request.class, WeatherMethod.Response.class) - .withDescription("Get the weather for the given location"); + final List tools = + List.of( + new OpenAiFunctionTool<>("weather", WeatherMethod::getCurrentWeather) + .withDescription("Get the weather for the given location")); // 2. Assistant calls the function - final var request = - new OpenAiChatCompletionRequest(messages).withOpenAiTools(List.of(weatherFunction)); + final var request = new OpenAiChatCompletionRequest(messages).withOpenAiTools(tools); final OpenAiChatCompletionResponse response = OpenAiClient.forModel(GPT_4O_MINI).chatCompletion(request); final OpenAiAssistantMessage assistantMessage = response.getMessage(); - - // 3. Execute the function - - + // 4. Send back the results, and the model will incorporate them into its final response. messages.add(assistantMessage); - messages.add(OpenAiMessage.tool(currentWeather.toString(), functionCall.getId())); + messages.addAll(response.executeTools(tools)); return OpenAiClient.forModel(GPT_4O_MINI).chatCompletion(request.withMessages(messages)); } From 36b018929f7640b5b94d4008331c21aca3f0b70e Mon Sep 17 00:00:00 2001 From: SAP Cloud SDK Bot Date: Wed, 9 Apr 2025 13:02:54 +0000 Subject: [PATCH 13/36] Formatting --- .../openai/OpenAiChatCompletionRequest.java | 6 +++--- .../openai/OpenAiChatCompletionResponse.java | 3 +-- .../sdk/foundationmodels/openai/OpenAiFunctionCall.java | 8 ++------ .../sdk/foundationmodels/openai/OpenAiFunctionTool.java | 7 ++++--- 4 files changed, 10 insertions(+), 14 deletions(-) diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequest.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequest.java index 99e5facb5..8ef781c97 100644 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequest.java +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequest.java @@ -3,7 +3,6 @@ import com.google.common.annotations.Beta; import com.google.common.collect.Lists; import com.sap.ai.sdk.foundationmodels.openai.generated.model.ChatCompletionStreamOptions; -import com.sap.ai.sdk.foundationmodels.openai.generated.model.ChatCompletionTool; import com.sap.ai.sdk.foundationmodels.openai.generated.model.ChatCompletionToolChoiceOption; import com.sap.ai.sdk.foundationmodels.openai.generated.model.CreateChatCompletionRequest; import com.sap.ai.sdk.foundationmodels.openai.generated.model.CreateChatCompletionRequestAllOfResponseFormat; @@ -124,7 +123,8 @@ public class OpenAiChatCompletionRequest { /** List of tools that the model may invoke during the completion. */ @Getter(value = AccessLevel.PACKAGE) - @Nullable List> tools; + @Nullable + List> tools; /** Option to control which tool is invoked by the model. */ @With(AccessLevel.PRIVATE) @@ -298,7 +298,7 @@ public OpenAiChatCompletionRequest withOpenAiTools(@Nonnull final List { if (tool instanceof OpenAiFunctionTool) { - return ((OpenAiFunctionTool) tool).createChatCompletionTool(); + return ((OpenAiFunctionTool) tool).createChatCompletionTool(); } else { throw new IllegalArgumentException( "Unsupported tool type: " + tool.getClass().getName()); 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 e5ee3b46a..b364102a6 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 @@ -17,7 +17,6 @@ import lombok.RequiredArgsConstructor; import lombok.Setter; import lombok.Value; -import lombok.val; /** * Represents the output of an OpenAI chat completion. * @@ -103,7 +102,7 @@ public OpenAiAssistantMessage getMessage() { return new OpenAiAssistantMessage(new OpenAiMessageContent(contentItems), openAiToolCalls); } - public List executeTools( List tools ) { + public List executeTools(List tools) { return getMessage().toolCalls().stream() .filter(toolCall -> toolCall instanceof OpenAiFunctionCall) .map(toolCall -> (OpenAiFunctionCall) toolCall) diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionCall.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionCall.java index 82110cfcc..28f73d60e 100644 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionCall.java +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionCall.java @@ -37,15 +37,11 @@ public class OpenAiFunctionCall implements OpenAiToolCall { * @since 1.7.0 */ @Nonnull - T parseArguments(@Nonnull final TypeReference request) - throws IllegalArgumentException { + T parseArguments(@Nonnull final TypeReference request) throws IllegalArgumentException { try { return getOpenAiObjectMapper().readValue(arguments, request); } catch (JsonProcessingException e) { - throw new IllegalArgumentException( - "Failed to parse JSON string to class " + request, e); + throw new IllegalArgumentException("Failed to parse JSON string to class " + request, e); } } - - } diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionTool.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionTool.java index 8f0e2e117..17ad665a3 100644 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionTool.java +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionTool.java @@ -67,10 +67,11 @@ ChatCompletionTool createChatCompletionTool() { final var objectMapper = new ObjectMapper(); JsonSchema schema = null; try { - schema = new JsonSchemaGenerator(objectMapper).generateSchema(new TypeReference() {}.getClass()); + schema = + new JsonSchemaGenerator(objectMapper) + .generateSchema(new TypeReference() {}.getClass()); } catch (JsonMappingException e) { - throw new IllegalArgumentException( - "Could not generate schema for " + name, e); + throw new IllegalArgumentException("Could not generate schema for " + name, e); } schema.setId(null); From bcb42c842449e98d4d8fb4b47dbce60e8c45dbf7 Mon Sep 17 00:00:00 2001 From: Roshin Rajan Panackal Date: Thu, 10 Apr 2025 14:02:02 +0200 Subject: [PATCH 14/36] With purely generics --- .../openai/OpenAiChatCompletionRequest.java | 20 +---- .../openai/OpenAiChatCompletionResponse.java | 37 +++----- .../foundationmodels/openai/OpenAiClient.java | 2 +- .../openai/OpenAiFunctionCall.java | 20 +++-- .../openai/OpenAiFunctionTool.java | 89 ------------------- .../foundationmodels/openai/OpenAiTool.java | 85 +++++++++++++++++- .../OpenAiChatCompletionRequestTest.java | 26 ++++-- .../openai/OpenAiToolCallTest.java | 21 +++-- .../ai/sdk/app/services/OpenAiServiceV2.java | 4 +- 9 files changed, 149 insertions(+), 155 deletions(-) delete mode 100644 foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionTool.java diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequest.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequest.java index 8ef781c97..db9c3c160 100644 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequest.java +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequest.java @@ -3,6 +3,7 @@ import com.google.common.annotations.Beta; import com.google.common.collect.Lists; import com.sap.ai.sdk.foundationmodels.openai.generated.model.ChatCompletionStreamOptions; +import com.sap.ai.sdk.foundationmodels.openai.generated.model.ChatCompletionTool; import com.sap.ai.sdk.foundationmodels.openai.generated.model.ChatCompletionToolChoiceOption; import com.sap.ai.sdk.foundationmodels.openai.generated.model.CreateChatCompletionRequest; import com.sap.ai.sdk.foundationmodels.openai.generated.model.CreateChatCompletionRequestAllOfResponseFormat; @@ -122,9 +123,7 @@ public class OpenAiChatCompletionRequest { @Nullable CreateChatCompletionRequestAllOfResponseFormat responseFormat; /** List of tools that the model may invoke during the completion. */ - @Getter(value = AccessLevel.PACKAGE) - @Nullable - List> tools; + @Nullable List tools; /** Option to control which tool is invoked by the model. */ @With(AccessLevel.PRIVATE) @@ -293,18 +292,7 @@ public OpenAiChatCompletionRequest withToolChoice(@Nonnull final OpenAiToolChoic */ @Nonnull public OpenAiChatCompletionRequest withOpenAiTools(@Nonnull final List tools) { - return this.withTools( - tools.stream() - .map( - tool -> { - if (tool instanceof OpenAiFunctionTool) { - return ((OpenAiFunctionTool) tool).createChatCompletionTool(); - } else { - throw new IllegalArgumentException( - "Unsupported tool type: " + tool.getClass().getName()); - } - }) - .toList()); + return this.withTools(tools.stream().map(OpenAiTool::createChatCompletionTool).toList()); } /** @@ -337,7 +325,7 @@ CreateChatCompletionRequest createCreateChatCompletionRequest() { request.seed(this.seed); request.streamOptions(this.streamOptions); request.responseFormat(this.responseFormat); - request.tools(this.tools.createChatCompletionTool()); + request.tools(this.tools); request.toolChoice(this.toolChoice); request.functionCall(null); request.functions(null); 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 b364102a6..badce504a 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 @@ -6,14 +6,13 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.google.common.annotations.Beta; -import com.sap.ai.sdk.foundationmodels.openai.generated.model.ChatCompletionTool; 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.ArrayList; import java.util.List; import java.util.Objects; import javax.annotation.Nonnull; -import javax.annotation.Nullable; import lombok.RequiredArgsConstructor; import lombok.Setter; import lombok.Value; @@ -31,8 +30,6 @@ public class OpenAiChatCompletionResponse { /** The original response from the OpenAI API. */ @Nonnull CreateChatCompletionResponse originalResponse; - @Nullable List functions; - /** * Gets the token usage from the original response. * @@ -103,29 +100,19 @@ public OpenAiAssistantMessage getMessage() { } public List executeTools(List tools) { - return getMessage().toolCalls().stream() - .filter(toolCall -> toolCall instanceof OpenAiFunctionCall) - .map(toolCall -> (OpenAiFunctionCall) toolCall) - .map( - functionCall -> { - OpenAiFunctionTool request = findFunction(tools, functionCall.getName()); - T arguments = functionCall.parseArguments(new TypeReference() {}); - R response = request.call(arguments); - return OpenAiMessage.tool(response, functionCall.getId()); - }) - .toList(); - } + var toolMessages = new ArrayList(); - @Nullable - private OpenAiFunctionTool findFunction(List tools, String name) { - if (functions == null) { - return null; - } - for (OpenAiTool tool : tools) { - if (tool instanceof OpenAiFunctionTool function && function.getName().equals(name)) { - return (OpenAiFunctionTool) function; + for (var toolCall : getMessage().toolCalls()) { + if (toolCall instanceof OpenAiFunctionCall functionCall) { + for (OpenAiTool tool : tools) { + if (tool.getName().equals(functionCall.getName())) { + T arguments = functionCall.getArgumentsAsObject(new TypeReference() {}); + R response = tool.call(arguments); + toolMessages.add(OpenAiMessage.tool(response.toString(), functionCall.getId())); + } + } } } - return null; + return toolMessages; } } 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 80d5a1a51..7cfcadb35 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 @@ -159,7 +159,7 @@ public OpenAiChatCompletionResponse chatCompletion( @Nonnull final OpenAiChatCompletionRequest request) throws OpenAiClientException { warnIfUnsupportedUsage(); return new OpenAiChatCompletionResponse( - chatCompletion(request.createCreateChatCompletionRequest()), request.getTools()); + chatCompletion(request.createCreateChatCompletionRequest())); } /** diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionCall.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionCall.java index 28f73d60e..7f6478356 100644 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionCall.java +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionCall.java @@ -5,6 +5,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.google.common.annotations.Beta; +import java.util.Map; import javax.annotation.Nonnull; import lombok.AllArgsConstructor; import lombok.Value; @@ -28,20 +29,25 @@ public class OpenAiFunctionCall implements OpenAiToolCall { @Nonnull String arguments; /** - * Parses the arguments, encoded as a JSON string, into an object of type expected by a function - * tool. + * Parses the arguments, encoded as a JSON string, into a {@code Map}. * - * @param request the type of the class - * @return the parsed arguments as an object + * @return a map of the arguments * @throws IllegalArgumentException if parsing fails * @since 1.7.0 */ @Nonnull - T parseArguments(@Nonnull final TypeReference request) throws IllegalArgumentException { + public Map getArgumentsAsMap() throws IllegalArgumentException { + return getArgumentsAsObject(new TypeReference<>() {}); + } + + @Nonnull + T getArgumentsAsObject(@Nonnull final TypeReference typeReference) + throws IllegalArgumentException { try { - return getOpenAiObjectMapper().readValue(arguments, request); + return getOpenAiObjectMapper().readValue(arguments, typeReference); } catch (JsonProcessingException e) { - throw new IllegalArgumentException("Failed to parse JSON string to class " + request, e); + throw new IllegalArgumentException( + "Failed to parse JSON string to class " + typeReference, e); } } } diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionTool.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionTool.java deleted file mode 100644 index 17ad665a3..000000000 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionTool.java +++ /dev/null @@ -1,89 +0,0 @@ -package com.sap.ai.sdk.foundationmodels.openai; - -import static com.sap.ai.sdk.foundationmodels.openai.generated.model.ChatCompletionTool.TypeEnum.FUNCTION; - -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.JsonMappingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.module.jsonSchema.JsonSchema; -import com.fasterxml.jackson.module.jsonSchema.JsonSchemaGenerator; -import com.google.common.annotations.Beta; -import com.sap.ai.sdk.foundationmodels.openai.generated.model.ChatCompletionTool; -import com.sap.ai.sdk.foundationmodels.openai.generated.model.FunctionObject; -import java.util.Map; -import java.util.function.Function; -import javax.annotation.Nonnull; -import javax.annotation.Nullable; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.Value; -import lombok.With; - -/** - * Represents an OpenAI function tool that can be used to define a function call in an OpenAI Chat - * Completion request. This tool generates a JSON schema based on the provided class representing - * the function's request structure. - * - * @see OpenAI Function - * @since 1.7.0 - */ -@Beta -@Value -@With -@Getter(AccessLevel.PACKAGE) -@AllArgsConstructor(access = AccessLevel.PRIVATE) -public class OpenAiFunctionTool implements OpenAiTool { - - /** The name of the function. */ - @Nonnull String name; - - /** The model class for function request. */ - @Nonnull Function function; - - /** An optional description of the function. */ - @Nullable String description; - - /** An optional flag indicating whether the function parameters should be treated strictly. */ - @Nullable Boolean strict; - - /** - * Constructs an {@code OpenAiFunctionTool} with the specified name and a model class that - * captures the request to the function. - * - * @param name the name of the function - * @param function the model class for function request - */ - public OpenAiFunctionTool(@Nonnull final String name, @Nonnull final Function function) { - this(name, function, null, null); - } - - @Nonnull - R call(@Nonnull final T request) { - return function.apply(request); - } - - ChatCompletionTool createChatCompletionTool() { - final var objectMapper = new ObjectMapper(); - JsonSchema schema = null; - try { - schema = - new JsonSchemaGenerator(objectMapper) - .generateSchema(new TypeReference() {}.getClass()); - } catch (JsonMappingException e) { - throw new IllegalArgumentException("Could not generate schema for " + name, e); - } - - schema.setId(null); - final var schemaMap = - objectMapper.convertValue(schema, new TypeReference>() {}); - - final var function = - new FunctionObject() - .name(getName()) - .description(getDescription()) - .parameters(schemaMap) - .strict(getStrict()); - return new ChatCompletionTool().type(FUNCTION).function(function); - } -} diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiTool.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiTool.java index 3c6675854..36c54e981 100644 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiTool.java +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiTool.java @@ -1,8 +1,89 @@ package com.sap.ai.sdk.foundationmodels.openai; +import static com.sap.ai.sdk.foundationmodels.openai.generated.model.ChatCompletionTool.TypeEnum.FUNCTION; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.module.jsonSchema.JsonSchema; +import com.fasterxml.jackson.module.jsonSchema.JsonSchemaGenerator; +import com.google.common.annotations.Beta; +import com.sap.ai.sdk.foundationmodels.openai.generated.model.ChatCompletionTool; +import com.sap.ai.sdk.foundationmodels.openai.generated.model.FunctionObject; +import java.util.Map; +import java.util.function.Function; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Value; +import lombok.With; + /** - * Represents a tool that can be integrated into an OpenAI Chat Completion request. + * Represents an OpenAI function tool that can be used to define a function call in an OpenAI Chat + * Completion request. This tool generates a JSON schema based on the provided class representing + * the function's request structure. * + * @see OpenAI Function * @since 1.7.0 */ -public sealed interface OpenAiTool permits OpenAiFunctionTool {} +@Beta +@Value +@With +@Getter(AccessLevel.PACKAGE) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class OpenAiTool { + + /** The name of the function. */ + @Nonnull String name; + + /** The model class for function request. */ + @Nonnull Function function; + + /** An optional description of the function. */ + @Nullable String description; + + /** An optional flag indicating whether the function parameters should be treated strictly. */ + @Nullable Boolean strict; + + /** + * Constructs an {@code OpenAiFunctionTool} with the specified name and a model class that + * captures the request to the function. + * + * @param name the name of the function + * @param function the model class for function request + */ + public OpenAiTool(@Nonnull final String name, @Nonnull final Function function) { + this(name, function, null, null); + } + + @Nonnull + R call(@Nonnull final T request) { + return function.apply(request); + } + + ChatCompletionTool createChatCompletionTool() { + final var objectMapper = new ObjectMapper(); + JsonSchema schema = null; + try { + schema = + new JsonSchemaGenerator(objectMapper) + .generateSchema(new TypeReference() {}.getClass()); + } catch (JsonMappingException e) { + throw new IllegalArgumentException("Could not generate schema for " + name, e); + } + + schema.setId(null); + final var schemaMap = + objectMapper.convertValue(schema, new TypeReference>() {}); + + final var function = + new FunctionObject() + .name(getName()) + .description(getDescription()) + .parameters(schemaMap) + .strict(getStrict()); + return new ChatCompletionTool().type(FUNCTION).function(function); + } +} diff --git a/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequestTest.java b/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequestTest.java index 7bf06ab2b..48b826ef3 100644 --- a/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequestTest.java +++ b/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequestTest.java @@ -11,6 +11,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.function.Function; import org.junit.jupiter.api.Test; class OpenAiChatCompletionRequestTest { @@ -120,17 +121,17 @@ void messageListExternallyUnmodifiable() { @Test void withOpenAiTools() { record DummyRequest(String param1, int param2) {} + record DummyResponse(String result) {} + + Function conCat = + (request) -> new DummyResponse(request.param1 + request.param2); var request = new OpenAiChatCompletionRequest(OpenAiMessage.user("Hello, world")) .withOpenAiTools( List.of( - new OpenAiFunctionTool("toolA", DummyRequest.class) - .withDescription("descA") - .withStrict(true), - new OpenAiFunctionTool("toolB", String.class) - .withDescription("descB") - .withStrict(false))); + new OpenAiTool("toolA", conCat).withDescription("descA").withStrict(true), + new OpenAiTool("toolB", conCat).withDescription("descB").withStrict(false))); var lowLevelRequest = request.createCreateChatCompletionRequest(); assertThat(lowLevelRequest.getTools()).hasSize(2); @@ -141,13 +142,24 @@ record DummyRequest(String param1, int param2) {} assertThat(toolA.getFunction().getName()).isEqualTo("toolA"); assertThat(toolA.getFunction().getDescription()).isEqualTo("descA"); assertThat(toolA.getFunction().isStrict()).isTrue(); + /// {"id"="urn:jsonschema:com:sap:ai:sdk:foundationmodels:openai:OpenAiTool:1", + // "properties"={"type"={"id"="urn:jsonschema:java:lang:reflect:Type", + // "properties"={"typeName"={"type"="string"}}, "type"="object"}}, "type"="object"} assertThat(toolA.getFunction().getParameters()) .isEqualTo( Map.of( "properties", - Map.of("param1", Map.of("type", "string"), "param2", Map.of("type", "integer")), + Map.of( + "type", + Map.of( + "properties", + Map.of("typeName", Map.of("type", "string")), + "type", + "object")), "type", "object")); + assertThat(toolA.getFunction().getParameters()) + .isEqualTo(Map.of("type", "string", "typeName", "java.lang.reflect.Type")); var toolB = lowLevelRequest.getTools().get(1); assertThat(toolB).isInstanceOf(ChatCompletionTool.class); diff --git a/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiToolCallTest.java b/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiToolCallTest.java index 3f1f5823b..aaa550fbb 100644 --- a/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiToolCallTest.java +++ b/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiToolCallTest.java @@ -3,6 +3,8 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import com.fasterxml.jackson.core.type.TypeReference; +import java.util.function.Function; import org.junit.jupiter.api.Test; class OpenAiToolCallTest { @@ -11,10 +13,16 @@ class OpenAiToolCallTest { private static final OpenAiFunctionCall INVALID_FUNCTION_CALL = new OpenAiFunctionCall("1", "functionName", "{invalid-json}"); - private static final OpenAiFunctionTool FUNCTION_TOOL = - new OpenAiFunctionTool("functionName", DummyRequest.class); + private static class Dummy { + record Request(String key) {} - record DummyRequest(String key) {} + record Response(String result) {} + + static Function conCat = request -> new Response(request.key()); + } + + private static final OpenAiTool TOOL = + new OpenAiTool("functionName", Dummy.conCat); @Test void getArgumentsAsMapParsesValidJson() { @@ -31,14 +39,15 @@ void getArgumentsAsMapThrowsOnInvalidJson() { @Test void getArgumentsAsObjectParsesValidJson() { - var result = (DummyRequest) VALID_FUNCTION_CALL.getArgumentsAsObject(FUNCTION_TOOL); - assertThat(result).isInstanceOf(DummyRequest.class); + var result = VALID_FUNCTION_CALL.getArgumentsAsObject(new TypeReference() {}); + assertThat(result).isInstanceOf(Dummy.Request.class); assertThat(result.key()).isEqualTo("value"); } @Test void getArgumentsAsObjectThrowsOnInvalidJson() { - assertThatThrownBy(() -> INVALID_FUNCTION_CALL.getArgumentsAsObject(FUNCTION_TOOL)) + assertThatThrownBy( + () -> INVALID_FUNCTION_CALL.getArgumentsAsObject(new TypeReference() {})) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("Failed to parse JSON string"); } diff --git a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OpenAiServiceV2.java b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OpenAiServiceV2.java index 17ec6a18b..74722abad 100644 --- a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OpenAiServiceV2.java +++ b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OpenAiServiceV2.java @@ -12,7 +12,6 @@ import com.sap.ai.sdk.foundationmodels.openai.OpenAiClient; import com.sap.ai.sdk.foundationmodels.openai.OpenAiEmbeddingRequest; import com.sap.ai.sdk.foundationmodels.openai.OpenAiEmbeddingResponse; -import com.sap.ai.sdk.foundationmodels.openai.OpenAiFunctionTool; import com.sap.ai.sdk.foundationmodels.openai.OpenAiImageItem; import com.sap.ai.sdk.foundationmodels.openai.OpenAiMessage; import com.sap.ai.sdk.foundationmodels.openai.OpenAiTool; @@ -99,7 +98,7 @@ public OpenAiChatCompletionResponse chatCompletionToolExecution( // 1. Define the function final List tools = List.of( - new OpenAiFunctionTool<>("weather", WeatherMethod::getCurrentWeather) + new OpenAiTool<>("weather", WeatherMethod::getCurrentWeather) .withDescription("Get the weather for the given location")); // 2. Assistant calls the function @@ -111,6 +110,7 @@ public OpenAiChatCompletionResponse chatCompletionToolExecution( // 4. Send back the results, and the model will incorporate them into its final response. messages.add(assistantMessage); messages.addAll(response.executeTools(tools)); + return OpenAiClient.forModel(GPT_4O_MINI).chatCompletion(request.withMessages(messages)); } From c52fafa4a72343c4e8dfe2028ed98d08f29c8320 Mon Sep 17 00:00:00 2001 From: Roshin Rajan Panackal Date: Thu, 10 Apr 2025 15:42:10 +0200 Subject: [PATCH 15/36] With purely explicit types --- .../openai/OpenAiChatCompletionRequest.java | 3 +- .../openai/OpenAiChatCompletionResponse.java | 7 ++- .../openai/OpenAiFunctionCall.java | 32 ++++++++++-- .../foundationmodels/openai/OpenAiTool.java | 51 +++++++++++++++---- .../OpenAiChatCompletionRequestTest.java | 35 +++++-------- .../openai/OpenAiToolCallTest.java | 7 ++- .../ai/sdk/app/services/OpenAiServiceV2.java | 8 +-- 7 files changed, 94 insertions(+), 49 deletions(-) diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequest.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequest.java index db9c3c160..6ad950d69 100644 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequest.java +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequest.java @@ -291,7 +291,8 @@ public OpenAiChatCompletionRequest withToolChoice(@Nonnull final OpenAiToolChoic * @since 1.7.0 */ @Nonnull - public OpenAiChatCompletionRequest withOpenAiTools(@Nonnull final List tools) { + public OpenAiChatCompletionRequest withOpenAiTools( + @Nonnull final List> tools) { return this.withTools(tools.stream().map(OpenAiTool::createChatCompletionTool).toList()); } 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 badce504a..f08a98ebc 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 @@ -4,7 +4,6 @@ import static lombok.AccessLevel.NONE; import static lombok.AccessLevel.PACKAGE; -import com.fasterxml.jackson.core.type.TypeReference; import com.google.common.annotations.Beta; import com.sap.ai.sdk.foundationmodels.openai.generated.model.CompletionUsage; import com.sap.ai.sdk.foundationmodels.openai.generated.model.CreateChatCompletionResponse; @@ -99,15 +98,15 @@ public OpenAiAssistantMessage getMessage() { return new OpenAiAssistantMessage(new OpenAiMessageContent(contentItems), openAiToolCalls); } - public List executeTools(List tools) { + public List executeTools(List> tools) { var toolMessages = new ArrayList(); for (var toolCall : getMessage().toolCalls()) { if (toolCall instanceof OpenAiFunctionCall functionCall) { for (OpenAiTool tool : tools) { if (tool.getName().equals(functionCall.getName())) { - T arguments = functionCall.getArgumentsAsObject(new TypeReference() {}); - R response = tool.call(arguments); + T arguments = functionCall.getArgumentsAsObject(tool); + R response = tool.execute(arguments); toolMessages.add(OpenAiMessage.tool(response.toString(), functionCall.getId())); } } diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionCall.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionCall.java index 7f6478356..190291e40 100644 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionCall.java +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionCall.java @@ -5,6 +5,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.google.common.annotations.Beta; +import java.lang.reflect.Type; import java.util.Map; import javax.annotation.Nonnull; import lombok.AllArgsConstructor; @@ -37,17 +38,40 @@ public class OpenAiFunctionCall implements OpenAiToolCall { */ @Nonnull public Map getArgumentsAsMap() throws IllegalArgumentException { - return getArgumentsAsObject(new TypeReference<>() {}); + return parseArguments(new TypeReference<>() {}); + } + + /** + * Parses the arguments, encoded as a JSON string, into an object of type expected by a function + * tool. + * + * @param tool the function tool the arguments are for + * @param the type of the class + * @return the parsed arguments as an object + * @throws IllegalArgumentException if parsing fails + * @since 1.7.0 + */ + @Nonnull + public T getArgumentsAsObject(@Nonnull final OpenAiTool tool) + throws IllegalArgumentException { + final var typeRef = + new TypeReference() { + @Override + public Type getType() { + return tool.getRequestClass(); + } + }; + return parseArguments(typeRef); } @Nonnull - T getArgumentsAsObject(@Nonnull final TypeReference typeReference) + private T parseArguments(@Nonnull final TypeReference typeReference) throws IllegalArgumentException { try { - return getOpenAiObjectMapper().readValue(arguments, typeReference); + return getOpenAiObjectMapper().readValue(getArguments(), typeReference); } catch (JsonProcessingException e) { throw new IllegalArgumentException( - "Failed to parse JSON string to class " + typeReference, e); + "Failed to parse JSON string to class " + typeReference.getType(), e); } } } diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiTool.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiTool.java index 36c54e981..3f445a2ce 100644 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiTool.java +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiTool.java @@ -16,9 +16,10 @@ import javax.annotation.Nullable; import lombok.AccessLevel; import lombok.AllArgsConstructor; +import lombok.Data; import lombok.Getter; -import lombok.Value; -import lombok.With; +import lombok.Setter; +import lombok.experimental.Accessors; /** * Represents an OpenAI function tool that can be used to define a function call in an OpenAI Chat @@ -29,9 +30,9 @@ * @since 1.7.0 */ @Beta -@Value -@With +@Data @Getter(AccessLevel.PACKAGE) +@Accessors(chain = true) @AllArgsConstructor(access = AccessLevel.PRIVATE) public class OpenAiTool { @@ -39,7 +40,7 @@ public class OpenAiTool { @Nonnull String name; /** The model class for function request. */ - @Nonnull Function function; + @Nonnull Class requestClass; /** An optional description of the function. */ @Nullable String description; @@ -47,20 +48,49 @@ public class OpenAiTool { /** An optional flag indicating whether the function parameters should be treated strictly. */ @Nullable Boolean strict; + /** The function to be called. */ + @Setter(AccessLevel.NONE) + @Nullable + Function function; + + /** The response class for the function. */ + @Setter(AccessLevel.NONE) + @Nullable + Class responseClass; + + public static OpenAiTool of(@Nonnull String name, @Nonnull Class requestClass) { + return new OpenAiTool<>(name, requestClass); + } + /** * Constructs an {@code OpenAiFunctionTool} with the specified name and a model class that * captures the request to the function. * * @param name the name of the function - * @param function the model class for function request + * @param requestClass the model class for function request + */ + private OpenAiTool(@Nonnull final String name, @Nonnull final Class requestClass) { + this(name, requestClass, null, null, null, null); + } + + /** + * Sets the function to be called and the response class for the function. + * + * @param function the function to be called + * @param responseClass the response class for the function + * @return this instance of {@code OpenAiFunctionTool} */ - public OpenAiTool(@Nonnull final String name, @Nonnull final Function function) { - this(name, function, null, null); + @Nonnull + public OpenAiTool setCallback( + @Nonnull final Function function, @Nonnull final Class responseClass) { + this.function = function; + this.responseClass = responseClass; + return this; } @Nonnull - R call(@Nonnull final T request) { - return function.apply(request); + R execute(@Nonnull final T argument) { + return function.apply(argument); } ChatCompletionTool createChatCompletionTool() { @@ -74,7 +104,6 @@ ChatCompletionTool createChatCompletionTool() { throw new IllegalArgumentException("Could not generate schema for " + name, e); } - schema.setId(null); final var schemaMap = objectMapper.convertValue(schema, new TypeReference>() {}); diff --git a/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequestTest.java b/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequestTest.java index 48b826ef3..610e61630 100644 --- a/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequestTest.java +++ b/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequestTest.java @@ -130,8 +130,12 @@ record DummyResponse(String result) {} new OpenAiChatCompletionRequest(OpenAiMessage.user("Hello, world")) .withOpenAiTools( List.of( - new OpenAiTool("toolA", conCat).withDescription("descA").withStrict(true), - new OpenAiTool("toolB", conCat).withDescription("descB").withStrict(false))); + OpenAiTool.of("toolA", DummyRequest.class) + .setDescription("descA") + .setStrict(true), + OpenAiTool.of("toolB", DummyRequest.class) + .setDescription("descB") + .setStrict(false))); var lowLevelRequest = request.createCreateChatCompletionRequest(); assertThat(lowLevelRequest.getTools()).hasSize(2); @@ -142,31 +146,18 @@ record DummyResponse(String result) {} assertThat(toolA.getFunction().getName()).isEqualTo("toolA"); assertThat(toolA.getFunction().getDescription()).isEqualTo("descA"); assertThat(toolA.getFunction().isStrict()).isTrue(); - /// {"id"="urn:jsonschema:com:sap:ai:sdk:foundationmodels:openai:OpenAiTool:1", - // "properties"={"type"={"id"="urn:jsonschema:java:lang:reflect:Type", - // "properties"={"typeName"={"type"="string"}}, "type"="object"}}, "type"="object"} + assertThat(toolA.getFunction().getParameters()) .isEqualTo( Map.of( + "id", "urn:jsonschema:com:sap:ai:sdk:foundationmodels:openai:OpenAiTool:1", "properties", - Map.of( - "type", Map.of( - "properties", - Map.of("typeName", Map.of("type", "string")), "type", - "object")), - "type", - "object")); - assertThat(toolA.getFunction().getParameters()) - .isEqualTo(Map.of("type", "string", "typeName", "java.lang.reflect.Type")); - - var toolB = lowLevelRequest.getTools().get(1); - assertThat(toolB).isInstanceOf(ChatCompletionTool.class); - assertThat(toolB.getType()).isEqualTo(ChatCompletionTool.TypeEnum.FUNCTION); - assertThat(toolB.getFunction().getName()).isEqualTo("toolB"); - assertThat(toolB.getFunction().getDescription()).isEqualTo("descB"); - assertThat(toolB.getFunction().isStrict()).isFalse(); - assertThat(toolB.getFunction().getParameters()).isEqualTo(Map.of("type", "string")); + Map.of( + "id", "urn:jsonschema:java:lang:reflect:Type", + "properties", Map.of("typeName", Map.of("type", "string")), + "type", "object")), + "type", "object")); } } diff --git a/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiToolCallTest.java b/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiToolCallTest.java index aaa550fbb..afa11675d 100644 --- a/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiToolCallTest.java +++ b/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiToolCallTest.java @@ -22,7 +22,7 @@ record Response(String result) {} } private static final OpenAiTool TOOL = - new OpenAiTool("functionName", Dummy.conCat); + OpenAiTool.of("functionName", Dummy.Request.class); @Test void getArgumentsAsMapParsesValidJson() { @@ -39,15 +39,14 @@ void getArgumentsAsMapThrowsOnInvalidJson() { @Test void getArgumentsAsObjectParsesValidJson() { - var result = VALID_FUNCTION_CALL.getArgumentsAsObject(new TypeReference() {}); + var result = VALID_FUNCTION_CALL.getArgumentsAsObject(TOOL); assertThat(result).isInstanceOf(Dummy.Request.class); assertThat(result.key()).isEqualTo("value"); } @Test void getArgumentsAsObjectThrowsOnInvalidJson() { - assertThatThrownBy( - () -> INVALID_FUNCTION_CALL.getArgumentsAsObject(new TypeReference() {})) + assertThatThrownBy(() -> INVALID_FUNCTION_CALL.getArgumentsAsObject(TOOL)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("Failed to parse JSON string"); } diff --git a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OpenAiServiceV2.java b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OpenAiServiceV2.java index 74722abad..3871a4e52 100644 --- a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OpenAiServiceV2.java +++ b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OpenAiServiceV2.java @@ -96,10 +96,12 @@ public OpenAiChatCompletionResponse chatCompletionToolExecution( messages.add(OpenAiMessage.user("What's the weather in %s in %s?".formatted(location, unit))); // 1. Define the function - final List tools = + final var tools = List.of( - new OpenAiTool<>("weather", WeatherMethod::getCurrentWeather) - .withDescription("Get the weather for the given location")); + OpenAiTool.of( + "weather", WeatherMethod.Request.class) + .setDescription("Get the weather for the given location") + .setCallback(WeatherMethod::getCurrentWeather, WeatherMethod.Response.class)); // 2. Assistant calls the function final var request = new OpenAiChatCompletionRequest(messages).withOpenAiTools(tools); From 66cef0100fd2ee3c0d341df09fc0147c965c3e38 Mon Sep 17 00:00:00 2001 From: Roshin Rajan Panackal Date: Thu, 10 Apr 2025 15:54:46 +0200 Subject: [PATCH 16/36] refactor: enhance response serialization and improve schema generation error handling --- .../openai/OpenAiChatCompletionResponse.java | 10 +++++++++- .../sap/ai/sdk/foundationmodels/openai/OpenAiTool.java | 6 ++---- 2 files changed, 11 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 f08a98ebc..97e9792aa 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 @@ -4,6 +4,7 @@ import static lombok.AccessLevel.NONE; import static lombok.AccessLevel.PACKAGE; +import com.fasterxml.jackson.core.JsonProcessingException; import com.google.common.annotations.Beta; import com.sap.ai.sdk.foundationmodels.openai.generated.model.CompletionUsage; import com.sap.ai.sdk.foundationmodels.openai.generated.model.CreateChatCompletionResponse; @@ -107,7 +108,14 @@ public List executeTools(List> tools) if (tool.getName().equals(functionCall.getName())) { T arguments = functionCall.getArgumentsAsObject(tool); R response = tool.execute(arguments); - toolMessages.add(OpenAiMessage.tool(response.toString(), functionCall.getId())); + + String serializedResponse; + try { + serializedResponse = OpenAiUtils.getOpenAiObjectMapper().writeValueAsString(response); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException(e); + } + toolMessages.add(OpenAiMessage.tool(serializedResponse, functionCall.getId())); } } } diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiTool.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiTool.java index 3f445a2ce..2384b4c80 100644 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiTool.java +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiTool.java @@ -97,11 +97,9 @@ ChatCompletionTool createChatCompletionTool() { final var objectMapper = new ObjectMapper(); JsonSchema schema = null; try { - schema = - new JsonSchemaGenerator(objectMapper) - .generateSchema(new TypeReference() {}.getClass()); + schema = new JsonSchemaGenerator(objectMapper).generateSchema(getRequestClass()); } catch (JsonMappingException e) { - throw new IllegalArgumentException("Could not generate schema for " + name, e); + throw new IllegalArgumentException("Could not generate schema for " + getRequestClass(), e); } final var schemaMap = From 3038eb3cae3121b8170f878dea4a1fd44e7be0b5 Mon Sep 17 00:00:00 2001 From: Roshin Rajan Panackal Date: Fri, 11 Apr 2025 17:40:50 +0200 Subject: [PATCH 17/36] Introduce OpenAiToolExecutor --- .../openai/OpenAiChatCompletionRequest.java | 3 +- .../openai/OpenAiChatCompletionResponse.java | 24 +------ .../openai/OpenAiFunctionCall.java | 10 +-- .../foundationmodels/openai/OpenAiTool.java | 48 +++++--------- .../openai/OpenAiToolExecutor.java | 64 +++++++++++++++++++ .../OpenAiChatCompletionRequestTest.java | 9 +-- .../openai/OpenAiToolCallTest.java | 7 +- .../ai/sdk/app/services/OpenAiServiceV2.java | 20 ++++-- 8 files changed, 104 insertions(+), 81 deletions(-) create mode 100644 foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiToolExecutor.java diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequest.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequest.java index 6ad950d69..3d1173c22 100644 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequest.java +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequest.java @@ -291,8 +291,7 @@ public OpenAiChatCompletionRequest withToolChoice(@Nonnull final OpenAiToolChoic * @since 1.7.0 */ @Nonnull - public OpenAiChatCompletionRequest withOpenAiTools( - @Nonnull final List> tools) { + public OpenAiChatCompletionRequest withOpenAiTools(@Nonnull final List> tools) { return this.withTools(tools.stream().map(OpenAiTool::createChatCompletionTool).toList()); } 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 97e9792aa..65d51baad 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 @@ -99,27 +99,5 @@ public OpenAiAssistantMessage getMessage() { return new OpenAiAssistantMessage(new OpenAiMessageContent(contentItems), openAiToolCalls); } - public List executeTools(List> tools) { - var toolMessages = new ArrayList(); - - for (var toolCall : getMessage().toolCalls()) { - if (toolCall instanceof OpenAiFunctionCall functionCall) { - for (OpenAiTool tool : tools) { - if (tool.getName().equals(functionCall.getName())) { - T arguments = functionCall.getArgumentsAsObject(tool); - R response = tool.execute(arguments); - - String serializedResponse; - try { - serializedResponse = OpenAiUtils.getOpenAiObjectMapper().writeValueAsString(response); - } catch (JsonProcessingException e) { - throw new IllegalArgumentException(e); - } - toolMessages.add(OpenAiMessage.tool(serializedResponse, functionCall.getId())); - } - } - } - } - return toolMessages; - } + } diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionCall.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionCall.java index 190291e40..c3f6068f2 100644 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionCall.java +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionCall.java @@ -52,16 +52,16 @@ public Map getArgumentsAsMap() throws IllegalArgumentException { * @since 1.7.0 */ @Nonnull - public T getArgumentsAsObject(@Nonnull final OpenAiTool tool) + public T getArgumentsAsObject(@Nonnull final OpenAiTool tool) throws IllegalArgumentException { - final var typeRef = - new TypeReference() { + + return parseArguments( + new TypeReference<>() { @Override public Type getType() { return tool.getRequestClass(); } - }; - return parseArguments(typeRef); + }); } @Nonnull diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiTool.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiTool.java index 2384b4c80..70ea44b13 100644 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiTool.java +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiTool.java @@ -34,33 +34,22 @@ @Getter(AccessLevel.PACKAGE) @Accessors(chain = true) @AllArgsConstructor(access = AccessLevel.PRIVATE) -public class OpenAiTool { +public class OpenAiTool { /** The name of the function. */ @Nonnull String name; /** The model class for function request. */ - @Nonnull Class requestClass; + @Nonnull Class requestClass; /** An optional description of the function. */ - @Nullable String description; + @Setter @Nullable String description; /** An optional flag indicating whether the function parameters should be treated strictly. */ - @Nullable Boolean strict; + @Setter @Nullable Boolean strict; /** The function to be called. */ - @Setter(AccessLevel.NONE) - @Nullable - Function function; - - /** The response class for the function. */ - @Setter(AccessLevel.NONE) - @Nullable - Class responseClass; - - public static OpenAiTool of(@Nonnull String name, @Nonnull Class requestClass) { - return new OpenAiTool<>(name, requestClass); - } + @Setter @Nullable Function function; /** * Constructs an {@code OpenAiFunctionTool} with the specified name and a model class that @@ -69,28 +58,21 @@ public static OpenAiTool of(@Nonnull String name, @Nonnull Class * @param name the name of the function * @param requestClass the model class for function request */ - private OpenAiTool(@Nonnull final String name, @Nonnull final Class requestClass) { - this(name, requestClass, null, null, null, null); + public OpenAiTool(@Nonnull final String name, @Nonnull final Class requestClass) { + this(name, requestClass, null, null, null); } - /** - * Sets the function to be called and the response class for the function. - * - * @param function the function to be called - * @param responseClass the response class for the function - * @return this instance of {@code OpenAiFunctionTool} - */ @Nonnull - public OpenAiTool setCallback( - @Nonnull final Function function, @Nonnull final Class responseClass) { - this.function = function; - this.responseClass = responseClass; - return this; + Object execute(@Nonnull final I argument) { + if (getFunction() == null) { + throw new IllegalStateException("Callback function is not set"); + } + return getFunction().apply(argument); } - @Nonnull - R execute(@Nonnull final T argument) { - return function.apply(argument); + public OpenAiTool setCallback(Function function) { + this.function = function; + return this; } ChatCompletionTool createChatCompletionTool() { diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiToolExecutor.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiToolExecutor.java new file mode 100644 index 000000000..9ee4ccdca --- /dev/null +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiToolExecutor.java @@ -0,0 +1,64 @@ +package com.sap.ai.sdk.foundationmodels.openai; + +import static lombok.AccessLevel.PRIVATE; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.annotations.Beta; +import java.util.List; +import java.util.stream.Collectors; +import javax.annotation.Nonnull; +import lombok.AllArgsConstructor; + +/** + * A class for OpenAI tool execution. + * + * @since 1.7.0 + */ +@Beta +@AllArgsConstructor(access = PRIVATE) +public class OpenAiToolExecutor { + + private static final ObjectMapper JACKSON = new ObjectMapper(); + + /** + * Executes the given tool calls with the provided tools and returns the results as a list of + * {@link OpenAiToolMessage}. + * + * @param tools the list of tools to execute + * @param toolCalls the list of tool calls with arguments + * @return the list of tool messages with the results + */ + @Nonnull + public static List executeTools( + List> tools, List toolCalls) { + + final var toolMap = tools.stream().collect(Collectors.toMap(OpenAiTool::getName, tool -> tool)); + + return toolCalls.stream() + .filter(OpenAiFunctionCall.class::isInstance) + .map(OpenAiFunctionCall.class::cast) + .filter(functionCall -> toolMap.containsKey(functionCall.getName())) + .map( + functionCall -> { + var tool = toolMap.get(functionCall.getName()); + var result = executeFunction(tool, functionCall); + return OpenAiMessage.tool(serializeObject(result), functionCall.getId()); + }) + .toList(); + } + + private static Object executeFunction(OpenAiTool tool, OpenAiFunctionCall toolCall) { + I arguments = toolCall.getArgumentsAsObject(tool); + return tool.execute(arguments); + } + + @Nonnull + private static String serializeObject(@Nonnull final Object obj) { + try { + return JACKSON.writeValueAsString(obj); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException(e); + } + } +} diff --git a/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequestTest.java b/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequestTest.java index 610e61630..44b9f9e0e 100644 --- a/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequestTest.java +++ b/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequestTest.java @@ -11,7 +11,6 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; -import java.util.function.Function; import org.junit.jupiter.api.Test; class OpenAiChatCompletionRequestTest { @@ -121,19 +120,15 @@ void messageListExternallyUnmodifiable() { @Test void withOpenAiTools() { record DummyRequest(String param1, int param2) {} - record DummyResponse(String result) {} - - Function conCat = - (request) -> new DummyResponse(request.param1 + request.param2); var request = new OpenAiChatCompletionRequest(OpenAiMessage.user("Hello, world")) .withOpenAiTools( List.of( - OpenAiTool.of("toolA", DummyRequest.class) + new OpenAiTool<>("toolA", DummyRequest.class) .setDescription("descA") .setStrict(true), - OpenAiTool.of("toolB", DummyRequest.class) + new OpenAiTool<>("toolB", DummyRequest.class) .setDescription("descB") .setStrict(false))); diff --git a/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiToolCallTest.java b/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiToolCallTest.java index afa11675d..be7e43108 100644 --- a/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiToolCallTest.java +++ b/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiToolCallTest.java @@ -3,7 +3,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import com.fasterxml.jackson.core.type.TypeReference; import java.util.function.Function; import org.junit.jupiter.api.Test; @@ -21,8 +20,8 @@ record Response(String result) {} static Function conCat = request -> new Response(request.key()); } - private static final OpenAiTool TOOL = - OpenAiTool.of("functionName", Dummy.Request.class); + private static final OpenAiTool TOOL = + new OpenAiTool<>("functionName", Dummy.Request.class); @Test void getArgumentsAsMapParsesValidJson() { @@ -39,7 +38,7 @@ void getArgumentsAsMapThrowsOnInvalidJson() { @Test void getArgumentsAsObjectParsesValidJson() { - var result = VALID_FUNCTION_CALL.getArgumentsAsObject(TOOL); + Dummy.Request result = VALID_FUNCTION_CALL.getArgumentsAsObject(TOOL); assertThat(result).isInstanceOf(Dummy.Request.class); assertThat(result.key()).isEqualTo("value"); } diff --git a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OpenAiServiceV2.java b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OpenAiServiceV2.java index 3871a4e52..9bd66f8f1 100644 --- a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OpenAiServiceV2.java +++ b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OpenAiServiceV2.java @@ -15,6 +15,8 @@ import com.sap.ai.sdk.foundationmodels.openai.OpenAiImageItem; import com.sap.ai.sdk.foundationmodels.openai.OpenAiMessage; import com.sap.ai.sdk.foundationmodels.openai.OpenAiTool; +import com.sap.ai.sdk.foundationmodels.openai.OpenAiToolExecutor; +import com.sap.ai.sdk.foundationmodels.openai.OpenAiToolMessage; import java.util.ArrayList; import java.util.List; import java.util.stream.Stream; @@ -83,7 +85,8 @@ public OpenAiChatCompletionResponse chatCompletionImage(@Nonnull final String li } /** - * Executes a chat completion request to OpenAI with a tool that calculates the weather. + * Chat request to OpenAI with tool that gets the weather for a given location and unit. The tool + * executed and the result is sent back to the assistant. * * @param location The location to get the weather for. * @param unit The unit of temperature to use. @@ -96,12 +99,11 @@ public OpenAiChatCompletionResponse chatCompletionToolExecution( messages.add(OpenAiMessage.user("What's the weather in %s in %s?".formatted(location, unit))); // 1. Define the function - final var tools = + final List> tools = List.of( - OpenAiTool.of( - "weather", WeatherMethod.Request.class) + new OpenAiTool<>("weather", WeatherMethod.Request.class) .setDescription("Get the weather for the given location") - .setCallback(WeatherMethod::getCurrentWeather, WeatherMethod.Response.class)); + .setCallback(WeatherMethod::getCurrentWeather)); // 2. Assistant calls the function final var request = new OpenAiChatCompletionRequest(messages).withOpenAiTools(tools); @@ -109,9 +111,13 @@ public OpenAiChatCompletionResponse chatCompletionToolExecution( OpenAiClient.forModel(GPT_4O_MINI).chatCompletion(request); final OpenAiAssistantMessage assistantMessage = response.getMessage(); - // 4. Send back the results, and the model will incorporate them into its final response. + // 3. Execute the tool call for given tools + List toolMessages = + OpenAiToolExecutor.executeTools(tools, assistantMessage.toolCalls()); + + // 4. Send back the results for model will incorporate them into its final response. messages.add(assistantMessage); - messages.addAll(response.executeTools(tools)); + messages.addAll(toolMessages); return OpenAiClient.forModel(GPT_4O_MINI).chatCompletion(request.withMessages(messages)); } From c53b04ea8f1b6653f430a0ce6feba94a756e6e3f Mon Sep 17 00:00:00 2001 From: Roshin Rajan Panackal Date: Fri, 11 Apr 2025 17:50:53 +0200 Subject: [PATCH 18/36] CI --- .../openai/OpenAiChatCompletionResponse.java | 4 ---- .../ai/sdk/foundationmodels/openai/OpenAiTool.java | 13 ++++--------- .../foundationmodels/openai/OpenAiToolExecutor.java | 12 +++++++----- .../openai/OpenAiChatCompletionRequestTest.java | 10 ++++------ .../sap/ai/sdk/app/services/OpenAiServiceV2.java | 2 +- 5 files changed, 16 insertions(+), 25 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 65d51baad..7bcf73854 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 @@ -4,12 +4,10 @@ import static lombok.AccessLevel.NONE; import static lombok.AccessLevel.PACKAGE; -import com.fasterxml.jackson.core.JsonProcessingException; import com.google.common.annotations.Beta; 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.ArrayList; import java.util.List; import java.util.Objects; import javax.annotation.Nonnull; @@ -98,6 +96,4 @@ public OpenAiAssistantMessage getMessage() { return new OpenAiAssistantMessage(new OpenAiMessageContent(contentItems), openAiToolCalls); } - - } diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiTool.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiTool.java index 70ea44b13..775ccc80d 100644 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiTool.java +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiTool.java @@ -18,7 +18,6 @@ import lombok.AllArgsConstructor; import lombok.Data; import lombok.Getter; -import lombok.Setter; import lombok.experimental.Accessors; /** @@ -26,6 +25,7 @@ * Completion request. This tool generates a JSON schema based on the provided class representing * the function's request structure. * + * @param the type of the input argument for the function * @see OpenAI Function * @since 1.7.0 */ @@ -43,13 +43,13 @@ public class OpenAiTool { @Nonnull Class requestClass; /** An optional description of the function. */ - @Setter @Nullable String description; + @Nullable String description; /** An optional flag indicating whether the function parameters should be treated strictly. */ - @Setter @Nullable Boolean strict; + @Nullable Boolean strict; /** The function to be called. */ - @Setter @Nullable Function function; + @Nullable Function function; /** * Constructs an {@code OpenAiFunctionTool} with the specified name and a model class that @@ -70,11 +70,6 @@ Object execute(@Nonnull final I argument) { return getFunction().apply(argument); } - public OpenAiTool setCallback(Function function) { - this.function = function; - return this; - } - ChatCompletionTool createChatCompletionTool() { final var objectMapper = new ObjectMapper(); JsonSchema schema = null; diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiToolExecutor.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiToolExecutor.java index 9ee4ccdca..e6ea1a841 100644 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiToolExecutor.java +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiToolExecutor.java @@ -31,7 +31,7 @@ public class OpenAiToolExecutor { */ @Nonnull public static List executeTools( - List> tools, List toolCalls) { + @Nonnull final List> tools, @Nonnull final List toolCalls) { final var toolMap = tools.stream().collect(Collectors.toMap(OpenAiTool::getName, tool -> tool)); @@ -41,15 +41,17 @@ public static List executeTools( .filter(functionCall -> toolMap.containsKey(functionCall.getName())) .map( functionCall -> { - var tool = toolMap.get(functionCall.getName()); - var result = executeFunction(tool, functionCall); + final var tool = toolMap.get(functionCall.getName()); + final var result = executeFunction(tool, functionCall); return OpenAiMessage.tool(serializeObject(result), functionCall.getId()); }) .toList(); } - private static Object executeFunction(OpenAiTool tool, OpenAiFunctionCall toolCall) { - I arguments = toolCall.getArgumentsAsObject(tool); + @Nonnull + private static Object executeFunction( + @Nonnull final OpenAiTool tool, @Nonnull final OpenAiFunctionCall toolCall) { + final I arguments = toolCall.getArgumentsAsObject(tool); return tool.execute(arguments); } diff --git a/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequestTest.java b/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequestTest.java index 44b9f9e0e..69f1ce1d9 100644 --- a/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequestTest.java +++ b/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequestTest.java @@ -145,14 +145,12 @@ record DummyRequest(String param1, int param2) {} assertThat(toolA.getFunction().getParameters()) .isEqualTo( Map.of( - "id", "urn:jsonschema:com:sap:ai:sdk:foundationmodels:openai:OpenAiTool:1", + "id", + "urn:jsonschema:com:sap:ai:sdk:foundationmodels:openai:OpenAiChatCompletionRequestTest:1DummyRequest", "properties", Map.of( - "type", - Map.of( - "id", "urn:jsonschema:java:lang:reflect:Type", - "properties", Map.of("typeName", Map.of("type", "string")), - "type", "object")), + "param1", Map.of("type", "string"), + "param2", Map.of("type", "integer")), "type", "object")); } } diff --git a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OpenAiServiceV2.java b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OpenAiServiceV2.java index 9bd66f8f1..d4fa5a04e 100644 --- a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OpenAiServiceV2.java +++ b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OpenAiServiceV2.java @@ -103,7 +103,7 @@ public OpenAiChatCompletionResponse chatCompletionToolExecution( List.of( new OpenAiTool<>("weather", WeatherMethod.Request.class) .setDescription("Get the weather for the given location") - .setCallback(WeatherMethod::getCurrentWeather)); + .setFunction(WeatherMethod::getCurrentWeather)); // 2. Assistant calls the function final var request = new OpenAiChatCompletionRequest(messages).withOpenAiTools(tools); From 27fa97551fa57617961723e75fb27ea4e57db521 Mon Sep 17 00:00:00 2001 From: Roshin Rajan Panackal Date: Tue, 15 Apr 2025 14:58:09 +0200 Subject: [PATCH 19/36] test: OpenAiToolExecutor and improve error messages --- .../foundationmodels/openai/OpenAiTool.java | 12 +- .../openai/OpenAiToolExecutor.java | 10 +- .../openai/OpenAiToolCallTest.java | 52 --------- .../openai/OpenAiToolTest.java | 106 ++++++++++++++++++ .../ai/sdk/app/services/OpenAiServiceV2.java | 2 +- 5 files changed, 119 insertions(+), 63 deletions(-) delete mode 100644 foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiToolCallTest.java create mode 100644 foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiToolTest.java diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiTool.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiTool.java index 775ccc80d..8316bddb1 100644 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiTool.java +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiTool.java @@ -37,19 +37,19 @@ public class OpenAiTool { /** The name of the function. */ - @Nonnull String name; + private @Nonnull String name; /** The model class for function request. */ - @Nonnull Class requestClass; + private @Nonnull Class requestClass; /** An optional description of the function. */ - @Nullable String description; + private @Nullable String description; /** An optional flag indicating whether the function parameters should be treated strictly. */ - @Nullable Boolean strict; + private @Nullable Boolean strict; /** The function to be called. */ - @Nullable Function function; + private @Nullable Function function; /** * Constructs an {@code OpenAiFunctionTool} with the specified name and a model class that @@ -65,7 +65,7 @@ public OpenAiTool(@Nonnull final String name, @Nonnull final Class requestCla @Nonnull Object execute(@Nonnull final I argument) { if (getFunction() == null) { - throw new IllegalStateException("Callback function is not set"); + throw new IllegalStateException("Function must not be set to execute the tool."); } return getFunction().apply(argument); } diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiToolExecutor.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiToolExecutor.java index e6ea1a841..c266f23d8 100644 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiToolExecutor.java +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiToolExecutor.java @@ -23,15 +23,17 @@ public class OpenAiToolExecutor { /** * Executes the given tool calls with the provided tools and returns the results as a list of - * {@link OpenAiToolMessage}. + * {@link OpenAiToolMessage} containing execution results encoded as JSON string. * * @param tools the list of tools to execute * @param toolCalls the list of tool calls with arguments * @return the list of tool messages with the results + * @throws IllegalArgumentException if the tool results cannot be serialized to JSON */ @Nonnull public static List executeTools( - @Nonnull final List> tools, @Nonnull final List toolCalls) { + @Nonnull final List> tools, @Nonnull final List toolCalls) + throws IllegalArgumentException { final var toolMap = tools.stream().collect(Collectors.toMap(OpenAiTool::getName, tool -> tool)); @@ -56,11 +58,11 @@ private static Object executeFunction( } @Nonnull - private static String serializeObject(@Nonnull final Object obj) { + private static String serializeObject(@Nonnull final Object obj) throws IllegalArgumentException { try { return JACKSON.writeValueAsString(obj); } catch (JsonProcessingException e) { - throw new IllegalArgumentException(e); + throw new IllegalArgumentException("Failed to serialize object to JSON", e); } } } diff --git a/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiToolCallTest.java b/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiToolCallTest.java deleted file mode 100644 index be7e43108..000000000 --- a/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiToolCallTest.java +++ /dev/null @@ -1,52 +0,0 @@ -package com.sap.ai.sdk.foundationmodels.openai; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import java.util.function.Function; -import org.junit.jupiter.api.Test; - -class OpenAiToolCallTest { - private static final OpenAiFunctionCall VALID_FUNCTION_CALL = - new OpenAiFunctionCall("1", "functionName", "{\"key\":\"value\"}"); - private static final OpenAiFunctionCall INVALID_FUNCTION_CALL = - new OpenAiFunctionCall("1", "functionName", "{invalid-json}"); - - private static class Dummy { - record Request(String key) {} - - record Response(String result) {} - - static Function conCat = request -> new Response(request.key()); - } - - private static final OpenAiTool TOOL = - new OpenAiTool<>("functionName", Dummy.Request.class); - - @Test - void getArgumentsAsMapParsesValidJson() { - var result = VALID_FUNCTION_CALL.getArgumentsAsMap(); - assertThat(result).containsEntry("key", "value"); - } - - @Test - void getArgumentsAsMapThrowsOnInvalidJson() { - assertThatThrownBy(INVALID_FUNCTION_CALL::getArgumentsAsMap) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("Failed to parse JSON string"); - } - - @Test - void getArgumentsAsObjectParsesValidJson() { - Dummy.Request result = VALID_FUNCTION_CALL.getArgumentsAsObject(TOOL); - assertThat(result).isInstanceOf(Dummy.Request.class); - assertThat(result.key()).isEqualTo("value"); - } - - @Test - void getArgumentsAsObjectThrowsOnInvalidJson() { - assertThatThrownBy(() -> INVALID_FUNCTION_CALL.getArgumentsAsObject(TOOL)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("Failed to parse JSON string"); - } -} diff --git a/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiToolTest.java b/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiToolTest.java new file mode 100644 index 000000000..0806c956a --- /dev/null +++ b/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiToolTest.java @@ -0,0 +1,106 @@ +package com.sap.ai.sdk.foundationmodels.openai; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import java.util.function.Function; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class OpenAiToolTest { + private final OpenAiFunctionCall functionCallA = + new OpenAiFunctionCall("1", "functionA", "{\"key\":\"value\"}"); + private final OpenAiFunctionCall functionCallB = + new OpenAiFunctionCall("2", "functionB", "{\"key\":\"value\"}"); + private final OpenAiFunctionCall invalidFunctionCallA = + new OpenAiFunctionCall("3", "functionA", "{invalid-json}"); + + private static class Dummy { + record Request(String key) {} + + record Response(String result) {} + + static final Function conCat = + request -> new Dummy.Response(request.key()); + } + + private OpenAiTool toolA; + + @BeforeEach + void setUp() { + toolA = new OpenAiTool<>("functionA", Dummy.Request.class); + } + + @Test + void getArgumentsAsMapValid() { + final var result = functionCallA.getArgumentsAsMap(); + assertThat(result).containsEntry("key", "value"); + } + + @Test + void getArgumentsAsMapInvalid() { + assertThatThrownBy(invalidFunctionCallA::getArgumentsAsMap) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Failed to parse JSON string"); + } + + @Test + void getArgumentsAsObjectValid() { + final Dummy.Request result = functionCallA.getArgumentsAsObject(toolA); + assertThat(result).isInstanceOf(Dummy.Request.class); + assertThat(result.key()).isEqualTo("value"); + } + + @Test + void getArgumentsAsObjectInvalid() { + assertThatThrownBy(() -> invalidFunctionCallA.getArgumentsAsObject(toolA)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Failed to parse JSON string"); + } + + @Test + void executeToolsValid() { + toolA.setFunction(Dummy.conCat); + final var result = OpenAiToolExecutor.executeTools(List.of(toolA), List.of(functionCallA)); + + assertThat(result).hasSize(1); + assertThat(result.get(0).toolCallId()).isEqualTo("1"); + assertThat(((OpenAiTextItem) result.get(0).content().items().get(0)).text()) + .isEqualTo("{\"result\":\"value\"}"); + } + + @Test + void executeToolsThrowsOnNoFunction() { + assertThatThrownBy( + () -> OpenAiToolExecutor.executeTools(List.of(toolA), List.of(functionCallA))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Function must not be set to execute the tool"); + } + + @Test + void executeToolsNoMatchingCall() { + final var toolAWithFunction = toolA.setFunction(Dummy.conCat); + final var result = + OpenAiToolExecutor.executeTools(List.of(toolAWithFunction), List.of(functionCallB)); + assertThat(result).isEmpty(); + } + + @Test + void executeToolsThrowsOnSerializationError() { + class NonSerializableResponse { + private String result; + + NonSerializableResponse(String result) { + this.result = result; + } + } + + toolA.setFunction(request -> new NonSerializableResponse(request.key())); + + assertThatThrownBy( + () -> OpenAiToolExecutor.executeTools(List.of(toolA), List.of(functionCallA))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Failed to serialize object to JSON"); + } +} diff --git a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OpenAiServiceV2.java b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OpenAiServiceV2.java index d4fa5a04e..393139c4b 100644 --- a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OpenAiServiceV2.java +++ b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OpenAiServiceV2.java @@ -112,7 +112,7 @@ public OpenAiChatCompletionResponse chatCompletionToolExecution( final OpenAiAssistantMessage assistantMessage = response.getMessage(); // 3. Execute the tool call for given tools - List toolMessages = + final List toolMessages = OpenAiToolExecutor.executeTools(tools, assistantMessage.toolCalls()); // 4. Send back the results for model will incorporate them into its final response. From e8764c889bff697146c511ed0d9378d3a801ab9c Mon Sep 17 00:00:00 2001 From: Roshin Rajan Panackal Date: Tue, 15 Apr 2025 15:17:58 +0200 Subject: [PATCH 20/36] docs: update release notes to include tool execution in OpenAI functionality --- docs/release_notes.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/release_notes.md b/docs/release_notes.md index 490694854..cb90f1a5c 100644 --- a/docs/release_notes.md +++ b/docs/release_notes.md @@ -12,7 +12,7 @@ ### ✨ New Functionality -- [OpenAI] [Add convenience for tool definition and parsing function calls](https://sap.github.io/ai-sdk/docs/java/foundation-models/openai/chat-completion#executing-tool-calls) +- [OpenAI] [Add convenience for tool definition, parsing function calls and tool execution](https://sap.github.io/ai-sdk/docs/java/foundation-models/openai/chat-completion#executing-tool-calls) ### 📈 Improvements From ad897533185a2c0c4dddb634da28e1d5e72db455 Mon Sep 17 00:00:00 2001 From: Roshin Rajan Panackal Date: Thu, 17 Apr 2025 17:51:33 +0200 Subject: [PATCH 21/36] fix: use the same schema generation dependency as in orchestration module --- foundation-models/openai/pom.xml | 8 ++- .../foundationmodels/openai/OpenAiTool.java | 55 +++++++++++-------- .../OpenAiChatCompletionRequestTest.java | 11 ++-- .../openai/OpenAiToolTest.java | 2 +- 4 files changed, 45 insertions(+), 31 deletions(-) diff --git a/foundation-models/openai/pom.xml b/foundation-models/openai/pom.xml index f9d31ef36..895b7a0c1 100644 --- a/foundation-models/openai/pom.xml +++ b/foundation-models/openai/pom.xml @@ -78,8 +78,12 @@ jackson-annotations - com.fasterxml.jackson.module - jackson-module-jsonSchema + com.github.victools + jsonschema-generator + + + com.github.victools + jsonschema-module-jackson io.vavr diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiTool.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiTool.java index 8316bddb1..5daaf436f 100644 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiTool.java +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiTool.java @@ -3,10 +3,13 @@ import static com.sap.ai.sdk.foundationmodels.openai.generated.model.ChatCompletionTool.TypeEnum.FUNCTION; import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.JsonMappingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.module.jsonSchema.JsonSchema; -import com.fasterxml.jackson.module.jsonSchema.JsonSchemaGenerator; +import com.github.victools.jsonschema.generator.Option; +import com.github.victools.jsonschema.generator.OptionPreset; +import com.github.victools.jsonschema.generator.SchemaGenerator; +import com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder; +import com.github.victools.jsonschema.generator.SchemaVersion; +import com.github.victools.jsonschema.module.jackson.JacksonModule; +import com.github.victools.jsonschema.module.jackson.JacksonOption; import com.google.common.annotations.Beta; import com.sap.ai.sdk.foundationmodels.openai.generated.model.ChatCompletionTool; import com.sap.ai.sdk.foundationmodels.openai.generated.model.FunctionObject; @@ -21,9 +24,9 @@ import lombok.experimental.Accessors; /** - * Represents an OpenAI function tool that can be used to define a function call in an OpenAI Chat - * Completion request. This tool generates a JSON schema based on the provided class representing - * the function's request structure. + * Represents an OpenAI tool that can be used to define a function call in an OpenAI Chat Completion + * request. This tool generates a JSON schema based on the provided class representing the + * function's request structure. * * @param the type of the input argument for the function * @see OpenAI Function @@ -36,20 +39,23 @@ @AllArgsConstructor(access = AccessLevel.PRIVATE) public class OpenAiTool { + /** The schema generator used to create JSON schemas. */ + @Nonnull private static final SchemaGenerator GENERATOR = createSchemaGenerator(); + /** The name of the function. */ - private @Nonnull String name; + @Nonnull private String name; /** The model class for function request. */ - private @Nonnull Class requestClass; + @Nonnull private Class requestClass; /** An optional description of the function. */ - private @Nullable String description; + @Nullable private String description; /** An optional flag indicating whether the function parameters should be treated strictly. */ - private @Nullable Boolean strict; + @Nullable private Boolean strict; /** The function to be called. */ - private @Nullable Function function; + @Nullable private Function function; /** * Constructs an {@code OpenAiFunctionTool} with the specified name and a model class that @@ -65,22 +71,16 @@ public OpenAiTool(@Nonnull final String name, @Nonnull final Class requestCla @Nonnull Object execute(@Nonnull final I argument) { if (getFunction() == null) { - throw new IllegalStateException("Function must not be set to execute the tool."); + throw new IllegalStateException("No function configured to execute."); } return getFunction().apply(argument); } ChatCompletionTool createChatCompletionTool() { - final var objectMapper = new ObjectMapper(); - JsonSchema schema = null; - try { - schema = new JsonSchemaGenerator(objectMapper).generateSchema(getRequestClass()); - } catch (JsonMappingException e) { - throw new IllegalArgumentException("Could not generate schema for " + getRequestClass(), e); - } - + final var schema = GENERATOR.generateSchema(getRequestClass()); final var schemaMap = - objectMapper.convertValue(schema, new TypeReference>() {}); + OpenAiUtils.getOpenAiObjectMapper() + .convertValue(schema, new TypeReference>() {}); final var function = new FunctionObject() @@ -90,4 +90,15 @@ ChatCompletionTool createChatCompletionTool() { .strict(getStrict()); return new ChatCompletionTool().type(FUNCTION).function(function); } + + private static SchemaGenerator createSchemaGenerator() { + final var module = + new JacksonModule( + JacksonOption.RESPECT_JSONPROPERTY_REQUIRED, JacksonOption.RESPECT_JSONPROPERTY_ORDER); + return new SchemaGenerator( + new SchemaGeneratorConfigBuilder(SchemaVersion.DRAFT_2020_12, OptionPreset.PLAIN_JSON) + .without(Option.SCHEMA_VERSION_INDICATOR) + .with(module) + .build()); + } } diff --git a/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequestTest.java b/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequestTest.java index 69f1ce1d9..71a9413b2 100644 --- a/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequestTest.java +++ b/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequestTest.java @@ -145,12 +145,11 @@ record DummyRequest(String param1, int param2) {} assertThat(toolA.getFunction().getParameters()) .isEqualTo( Map.of( - "id", - "urn:jsonschema:com:sap:ai:sdk:foundationmodels:openai:OpenAiChatCompletionRequestTest:1DummyRequest", "properties", - Map.of( - "param1", Map.of("type", "string"), - "param2", Map.of("type", "integer")), - "type", "object")); + Map.of( + "param1", Map.of("type", "string"), + "param2", Map.of("type", "integer")), + "type", + "object")); } } diff --git a/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiToolTest.java b/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiToolTest.java index 0806c956a..8040e33f6 100644 --- a/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiToolTest.java +++ b/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiToolTest.java @@ -75,7 +75,7 @@ void executeToolsThrowsOnNoFunction() { assertThatThrownBy( () -> OpenAiToolExecutor.executeTools(List.of(toolA), List.of(functionCallA))) .isInstanceOf(IllegalStateException.class) - .hasMessageContaining("Function must not be set to execute the tool"); + .hasMessageContaining("No function configured to execute."); } @Test From f8e3645c9d0e7f037408d6a815cbe16b43bf32e4 Mon Sep 17 00:00:00 2001 From: Roshin Rajan Panackal Date: Thu, 17 Apr 2025 17:52:30 +0200 Subject: [PATCH 22/36] refactor: rename generic type parameter from I to InputT in OpenAiTool --- .../ai/sdk/foundationmodels/openai/OpenAiTool.java | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiTool.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiTool.java index 5daaf436f..0daab1d0f 100644 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiTool.java +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiTool.java @@ -28,7 +28,7 @@ * request. This tool generates a JSON schema based on the provided class representing the * function's request structure. * - * @param the type of the input argument for the function + * @param the type of the input argument for the function * @see OpenAI Function * @since 1.7.0 */ @@ -37,7 +37,7 @@ @Getter(AccessLevel.PACKAGE) @Accessors(chain = true) @AllArgsConstructor(access = AccessLevel.PRIVATE) -public class OpenAiTool { +public class OpenAiTool { /** The schema generator used to create JSON schemas. */ @Nonnull private static final SchemaGenerator GENERATOR = createSchemaGenerator(); @@ -46,7 +46,7 @@ public class OpenAiTool { @Nonnull private String name; /** The model class for function request. */ - @Nonnull private Class requestClass; + @Nonnull private Class requestClass; /** An optional description of the function. */ @Nullable private String description; @@ -55,7 +55,7 @@ public class OpenAiTool { @Nullable private Boolean strict; /** The function to be called. */ - @Nullable private Function function; + @Nullable private Function function; /** * Constructs an {@code OpenAiFunctionTool} with the specified name and a model class that @@ -64,12 +64,12 @@ public class OpenAiTool { * @param name the name of the function * @param requestClass the model class for function request */ - public OpenAiTool(@Nonnull final String name, @Nonnull final Class requestClass) { + public OpenAiTool(@Nonnull final String name, @Nonnull final Class requestClass) { this(name, requestClass, null, null, null); } @Nonnull - Object execute(@Nonnull final I argument) { + Object execute(@Nonnull final InputT argument) { if (getFunction() == null) { throw new IllegalStateException("No function configured to execute."); } From c4bfa20d4e4bab93749a5c8cd16666bb17fcac2f Mon Sep 17 00:00:00 2001 From: Roshin Rajan Panackal Date: Thu, 17 Apr 2025 17:56:16 +0200 Subject: [PATCH 23/36] fix shadowing problem --- .../sdk/foundationmodels/openai/OpenAiTool.java | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiTool.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiTool.java index 0daab1d0f..d9a22c1fa 100644 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiTool.java +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiTool.java @@ -82,13 +82,14 @@ ChatCompletionTool createChatCompletionTool() { OpenAiUtils.getOpenAiObjectMapper() .convertValue(schema, new TypeReference>() {}); - final var function = - new FunctionObject() - .name(getName()) - .description(getDescription()) - .parameters(schemaMap) - .strict(getStrict()); - return new ChatCompletionTool().type(FUNCTION).function(function); + return new ChatCompletionTool() + .type(FUNCTION) + .function( + new FunctionObject() + .name(getName()) + .description(getDescription()) + .parameters(schemaMap) + .strict(getStrict())); } private static SchemaGenerator createSchemaGenerator() { From d15ee63aaa78a5f47f2e6b075723dde7dd8769c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20D=C3=BCmont?= Date: Mon, 28 Apr 2025 16:53:30 +0200 Subject: [PATCH 24/36] Change `withOpenAiTools` to `withToolsExecutable`; Change `OpenAiToolExecutor.executeTools` to `OpenAiTool.execute`; Change mandatory argument value `assistantMessage.toolCalls()` to `assistantMessage`; Add intermediate API to optionally extract original execution result objects to `getMessages()` convenience method --- .../openai/OpenAiChatCompletionRequest.java | 3 +- .../openai/OpenAiFunctionCall.java | 27 ++---- .../foundationmodels/openai/OpenAiTool.java | 89 ++++++++++++++++++- .../openai/OpenAiToolExecutor.java | 68 -------------- .../OpenAiChatCompletionRequestTest.java | 2 +- .../openai/OpenAiToolTest.java | 63 ++++++++----- .../ai/sdk/app/services/OpenAiServiceV2.java | 18 ++-- 7 files changed, 145 insertions(+), 125 deletions(-) delete mode 100644 foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiToolExecutor.java diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequest.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequest.java index 3d1173c22..e0235f64e 100644 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequest.java +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequest.java @@ -291,7 +291,8 @@ public OpenAiChatCompletionRequest withToolChoice(@Nonnull final OpenAiToolChoic * @since 1.7.0 */ @Nonnull - public OpenAiChatCompletionRequest withOpenAiTools(@Nonnull final List> tools) { + @Beta + public OpenAiChatCompletionRequest withToolsExecutable(@Nonnull final List> tools) { return this.withTools(tools.stream().map(OpenAiTool::createChatCompletionTool).toList()); } diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionCall.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionCall.java index c3f6068f2..9aee03190 100644 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionCall.java +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionCall.java @@ -3,9 +3,7 @@ import static com.sap.ai.sdk.foundationmodels.openai.OpenAiUtils.getOpenAiObjectMapper; import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.core.type.TypeReference; import com.google.common.annotations.Beta; -import java.lang.reflect.Type; import java.util.Map; import javax.annotation.Nonnull; import lombok.AllArgsConstructor; @@ -38,40 +36,25 @@ public class OpenAiFunctionCall implements OpenAiToolCall { */ @Nonnull public Map getArgumentsAsMap() throws IllegalArgumentException { - return parseArguments(new TypeReference<>() {}); + return getArgumentsAsObject(Map.class); } /** * Parses the arguments, encoded as a JSON string, into an object of type expected by a function * tool. * - * @param tool the function tool the arguments are for + * @param type the class reference for requested type. * @param the type of the class * @return the parsed arguments as an object * @throws IllegalArgumentException if parsing fails * @since 1.7.0 */ @Nonnull - public T getArgumentsAsObject(@Nonnull final OpenAiTool tool) - throws IllegalArgumentException { - - return parseArguments( - new TypeReference<>() { - @Override - public Type getType() { - return tool.getRequestClass(); - } - }); - } - - @Nonnull - private T parseArguments(@Nonnull final TypeReference typeReference) - throws IllegalArgumentException { + public T getArgumentsAsObject(@Nonnull final Class type) throws IllegalArgumentException { try { - return getOpenAiObjectMapper().readValue(getArguments(), typeReference); + return getOpenAiObjectMapper().readValue(getArguments(), type); } catch (JsonProcessingException e) { - throw new IllegalArgumentException( - "Failed to parse JSON string to class " + typeReference.getType(), e); + throw new IllegalArgumentException("Failed to parse JSON string to class " + type, e); } } } diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiTool.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiTool.java index d9a22c1fa..2039a7002 100644 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiTool.java +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiTool.java @@ -1,8 +1,11 @@ package com.sap.ai.sdk.foundationmodels.openai; import static com.sap.ai.sdk.foundationmodels.openai.generated.model.ChatCompletionTool.TypeEnum.FUNCTION; +import static java.util.function.UnaryOperator.identity; +import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; import com.github.victools.jsonschema.generator.Option; import com.github.victools.jsonschema.generator.OptionPreset; import com.github.victools.jsonschema.generator.SchemaGenerator; @@ -13,15 +16,21 @@ import com.google.common.annotations.Beta; import com.sap.ai.sdk.foundationmodels.openai.generated.model.ChatCompletionTool; import com.sap.ai.sdk.foundationmodels.openai.generated.model.FunctionObject; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.function.Function; +import java.util.stream.Collectors; import javax.annotation.Nonnull; import javax.annotation.Nullable; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Data; import lombok.Getter; +import lombok.RequiredArgsConstructor; import lombok.experimental.Accessors; +import lombok.extern.slf4j.Slf4j; /** * Represents an OpenAI tool that can be used to define a function call in an OpenAI Chat Completion @@ -32,6 +41,7 @@ * @see OpenAI Function * @since 1.7.0 */ +@Slf4j @Beta @Data @Getter(AccessLevel.PACKAGE) @@ -39,6 +49,8 @@ @AllArgsConstructor(access = AccessLevel.PRIVATE) public class OpenAiTool { + private static final ObjectMapper JACKSON = new ObjectMapper(); + /** The schema generator used to create JSON schemas. */ @Nonnull private static final SchemaGenerator GENERATOR = createSchemaGenerator(); @@ -71,7 +83,8 @@ public OpenAiTool(@Nonnull final String name, @Nonnull final Class reque @Nonnull Object execute(@Nonnull final InputT argument) { if (getFunction() == null) { - throw new IllegalStateException("No function configured to execute."); + throw new IllegalStateException( + "Tool " + name + " is missing a method reference to execute."); } return getFunction().apply(argument); } @@ -102,4 +115,78 @@ private static SchemaGenerator createSchemaGenerator() { .with(module) .build()); } + + /** + * Executes the given tool calls with the provided tools and returns the results as a list of + * {@link OpenAiToolMessage} containing execution results encoded as JSON string. + * + * @param tools the list of tools to execute + * @param msg the assistant message containing a list of tool calls with arguments + * @return a result object that contains the list of tool messages with the results + * @throws IllegalStateException if a tool is missing a method reference for function execution. + */ + @Beta + @Nonnull + public static Execution execute( + @Nonnull final List> tools, @Nonnull final OpenAiAssistantMessage msg) + throws IllegalArgumentException { + final var result = new LinkedHashMap(); + + final var toolMap = tools.stream().collect(Collectors.toMap(OpenAiTool::getName, identity())); + for (final OpenAiToolCall toolCall : msg.toolCalls()) { + if (toolCall instanceof OpenAiFunctionCall functionCall) { + final var tool = toolMap.get(functionCall.getName()); + if (tool == null) { + log.warn("Tool not found for function call: {}", functionCall.getName()); + continue; + } + final var toolResult = executeFunction(tool, functionCall); + result.put(functionCall, toolResult); + } + } + return new Execution(result); + } + + @Nonnull + private static Object executeFunction( + @Nonnull final OpenAiTool tool, @Nonnull final OpenAiFunctionCall toolCall) { + final I arguments = toolCall.getArgumentsAsObject(tool.getRequestClass()); + return tool.execute(arguments); + } + + @Nonnull + private static String serializeObject(@Nonnull final Object obj) throws IllegalArgumentException { + try { + return JACKSON.writeValueAsString(obj); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException("Failed to serialize object to JSON", e); + } + } + + /** + * Represents the result of executing a tool call, containing the results of the function calls. + */ + @RequiredArgsConstructor + @Beta + public static class Execution { + @Getter @Beta @Nonnull private final Map results; + + /** + * Creates a new list of serialized OpenAI tool messages. + * + * @return the list of serialized OpenAI tool messages. + * @throws IllegalArgumentException if the tool results cannot be serialized to JSON + */ + @Beta + @Nonnull + public List getMessages() { + final var result = new ArrayList(); + for (final var entry : getResults().entrySet()) { + final var functionCall = entry.getKey().getId(); + final var serializedValue = serializeObject(entry.getValue()); + result.add(OpenAiMessage.tool(serializedValue, functionCall)); + } + return result; + } + } } diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiToolExecutor.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiToolExecutor.java deleted file mode 100644 index c266f23d8..000000000 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiToolExecutor.java +++ /dev/null @@ -1,68 +0,0 @@ -package com.sap.ai.sdk.foundationmodels.openai; - -import static lombok.AccessLevel.PRIVATE; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.google.common.annotations.Beta; -import java.util.List; -import java.util.stream.Collectors; -import javax.annotation.Nonnull; -import lombok.AllArgsConstructor; - -/** - * A class for OpenAI tool execution. - * - * @since 1.7.0 - */ -@Beta -@AllArgsConstructor(access = PRIVATE) -public class OpenAiToolExecutor { - - private static final ObjectMapper JACKSON = new ObjectMapper(); - - /** - * Executes the given tool calls with the provided tools and returns the results as a list of - * {@link OpenAiToolMessage} containing execution results encoded as JSON string. - * - * @param tools the list of tools to execute - * @param toolCalls the list of tool calls with arguments - * @return the list of tool messages with the results - * @throws IllegalArgumentException if the tool results cannot be serialized to JSON - */ - @Nonnull - public static List executeTools( - @Nonnull final List> tools, @Nonnull final List toolCalls) - throws IllegalArgumentException { - - final var toolMap = tools.stream().collect(Collectors.toMap(OpenAiTool::getName, tool -> tool)); - - return toolCalls.stream() - .filter(OpenAiFunctionCall.class::isInstance) - .map(OpenAiFunctionCall.class::cast) - .filter(functionCall -> toolMap.containsKey(functionCall.getName())) - .map( - functionCall -> { - final var tool = toolMap.get(functionCall.getName()); - final var result = executeFunction(tool, functionCall); - return OpenAiMessage.tool(serializeObject(result), functionCall.getId()); - }) - .toList(); - } - - @Nonnull - private static Object executeFunction( - @Nonnull final OpenAiTool tool, @Nonnull final OpenAiFunctionCall toolCall) { - final I arguments = toolCall.getArgumentsAsObject(tool); - return tool.execute(arguments); - } - - @Nonnull - private static String serializeObject(@Nonnull final Object obj) throws IllegalArgumentException { - try { - return JACKSON.writeValueAsString(obj); - } catch (JsonProcessingException e) { - throw new IllegalArgumentException("Failed to serialize object to JSON", e); - } - } -} diff --git a/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequestTest.java b/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequestTest.java index 71a9413b2..6d110bd80 100644 --- a/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequestTest.java +++ b/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequestTest.java @@ -123,7 +123,7 @@ record DummyRequest(String param1, int param2) {} var request = new OpenAiChatCompletionRequest(OpenAiMessage.user("Hello, world")) - .withOpenAiTools( + .withToolsExecutable( List.of( new OpenAiTool<>("toolA", DummyRequest.class) .setDescription("descA") diff --git a/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiToolTest.java b/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiToolTest.java index 8040e33f6..fa4c55879 100644 --- a/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiToolTest.java +++ b/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiToolTest.java @@ -3,23 +3,29 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import java.util.Collections; import java.util.List; +import java.util.Map; import java.util.function.Function; +import lombok.EqualsAndHashCode; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; class OpenAiToolTest { - private final OpenAiFunctionCall functionCallA = + private static final OpenAiFunctionCall FUNCTION_CALL_A = new OpenAiFunctionCall("1", "functionA", "{\"key\":\"value\"}"); - private final OpenAiFunctionCall functionCallB = + private static final OpenAiFunctionCall FUNCTION_CALL_B = new OpenAiFunctionCall("2", "functionB", "{\"key\":\"value\"}"); - private final OpenAiFunctionCall invalidFunctionCallA = + private static final OpenAiFunctionCall INVALID_FUNCTION_CALL_A = new OpenAiFunctionCall("3", "functionA", "{invalid-json}"); + private static final OpenAiMessageContent EMPTY_MSG_CONTENT = + new OpenAiMessageContent(Collections.emptyList()); + private static class Dummy { record Request(String key) {} - record Response(String result) {} + record Response(String toolMsg) {} static final Function conCat = request -> new Dummy.Response(request.key()); @@ -34,27 +40,27 @@ void setUp() { @Test void getArgumentsAsMapValid() { - final var result = functionCallA.getArgumentsAsMap(); + final var result = FUNCTION_CALL_A.getArgumentsAsMap(); assertThat(result).containsEntry("key", "value"); } @Test void getArgumentsAsMapInvalid() { - assertThatThrownBy(invalidFunctionCallA::getArgumentsAsMap) + assertThatThrownBy(INVALID_FUNCTION_CALL_A::getArgumentsAsMap) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("Failed to parse JSON string"); } @Test void getArgumentsAsObjectValid() { - final Dummy.Request result = functionCallA.getArgumentsAsObject(toolA); + final Dummy.Request result = FUNCTION_CALL_A.getArgumentsAsObject(toolA.getRequestClass()); assertThat(result).isInstanceOf(Dummy.Request.class); assertThat(result.key()).isEqualTo("value"); } @Test void getArgumentsAsObjectInvalid() { - assertThatThrownBy(() -> invalidFunctionCallA.getArgumentsAsObject(toolA)) + assertThatThrownBy(() -> INVALID_FUNCTION_CALL_A.getArgumentsAsObject(toolA.getRequestClass())) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("Failed to parse JSON string"); } @@ -62,32 +68,41 @@ void getArgumentsAsObjectInvalid() { @Test void executeToolsValid() { toolA.setFunction(Dummy.conCat); - final var result = OpenAiToolExecutor.executeTools(List.of(toolA), List.of(functionCallA)); - - assertThat(result).hasSize(1); - assertThat(result.get(0).toolCallId()).isEqualTo("1"); - assertThat(((OpenAiTextItem) result.get(0).content().items().get(0)).text()) - .isEqualTo("{\"result\":\"value\"}"); + final var assistMsg = new OpenAiAssistantMessage(EMPTY_MSG_CONTENT, List.of(FUNCTION_CALL_A)); + final var execution = OpenAiTool.execute(List.of(toolA), assistMsg); + + final var results = execution.getResults(); + assertThat(results) + .hasSize(1) + .containsExactly(Map.entry(FUNCTION_CALL_A, new Dummy.Response("value"))); + + final var toolMsg = execution.getMessages(); + assertThat(toolMsg).hasSize(1); + assertThat(toolMsg.get(0).toolCallId()).isEqualTo("1"); + assertThat(((OpenAiTextItem) toolMsg.get(0).content().items().get(0)).text()) + .isEqualTo("{\"toolMsg\":\"value\"}"); } @Test void executeToolsThrowsOnNoFunction() { - assertThatThrownBy( - () -> OpenAiToolExecutor.executeTools(List.of(toolA), List.of(functionCallA))) + final var assistMsg = new OpenAiAssistantMessage(EMPTY_MSG_CONTENT, List.of(FUNCTION_CALL_A)); + assertThatThrownBy(() -> OpenAiTool.execute(List.of(toolA), assistMsg)) .isInstanceOf(IllegalStateException.class) - .hasMessageContaining("No function configured to execute."); + .hasMessageContaining("Tool functionA is missing a method reference to execute."); } @Test void executeToolsNoMatchingCall() { final var toolAWithFunction = toolA.setFunction(Dummy.conCat); - final var result = - OpenAiToolExecutor.executeTools(List.of(toolAWithFunction), List.of(functionCallB)); - assertThat(result).isEmpty(); + final var assistMsg = new OpenAiAssistantMessage(EMPTY_MSG_CONTENT, List.of(FUNCTION_CALL_B)); + final var executions = OpenAiTool.execute(List.of(toolAWithFunction), assistMsg); + assertThat(executions.getResults()).isEmpty(); + assertThat(executions.getMessages()).isEmpty(); } @Test void executeToolsThrowsOnSerializationError() { + @EqualsAndHashCode class NonSerializableResponse { private String result; @@ -97,9 +112,13 @@ class NonSerializableResponse { } toolA.setFunction(request -> new NonSerializableResponse(request.key())); + final var assistMsg = new OpenAiAssistantMessage(EMPTY_MSG_CONTENT, List.of(FUNCTION_CALL_A)); + final var executions = OpenAiTool.execute(List.of(toolA), assistMsg); + + assertThat(executions.getResults()) + .containsExactly(Map.entry(FUNCTION_CALL_A, new NonSerializableResponse("value"))); - assertThatThrownBy( - () -> OpenAiToolExecutor.executeTools(List.of(toolA), List.of(functionCallA))) + assertThatThrownBy(executions::getMessages) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("Failed to serialize object to JSON"); } diff --git a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OpenAiServiceV2.java b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OpenAiServiceV2.java index 393139c4b..5890087b0 100644 --- a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OpenAiServiceV2.java +++ b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OpenAiServiceV2.java @@ -15,8 +15,6 @@ import com.sap.ai.sdk.foundationmodels.openai.OpenAiImageItem; import com.sap.ai.sdk.foundationmodels.openai.OpenAiMessage; import com.sap.ai.sdk.foundationmodels.openai.OpenAiTool; -import com.sap.ai.sdk.foundationmodels.openai.OpenAiToolExecutor; -import com.sap.ai.sdk.foundationmodels.openai.OpenAiToolMessage; import java.util.ArrayList; import java.util.List; import java.util.stream.Stream; @@ -95,6 +93,8 @@ public OpenAiChatCompletionResponse chatCompletionImage(@Nonnull final String li @Nonnull public OpenAiChatCompletionResponse chatCompletionToolExecution( @Nonnull final String location, @Nonnull final String unit) { + final OpenAiClient client = OpenAiClient.forModel(GPT_4O_MINI); + final var messages = new ArrayList(); messages.add(OpenAiMessage.user("What's the weather in %s in %s?".formatted(location, unit))); @@ -106,20 +106,18 @@ public OpenAiChatCompletionResponse chatCompletionToolExecution( .setFunction(WeatherMethod::getCurrentWeather)); // 2. Assistant calls the function - final var request = new OpenAiChatCompletionRequest(messages).withOpenAiTools(tools); - final OpenAiChatCompletionResponse response = - OpenAiClient.forModel(GPT_4O_MINI).chatCompletion(request); - final OpenAiAssistantMessage assistantMessage = response.getMessage(); + final var request = new OpenAiChatCompletionRequest(messages).withToolsExecutable(tools); + final OpenAiChatCompletionResponse response = client.chatCompletion(request); // 3. Execute the tool call for given tools - final List toolMessages = - OpenAiToolExecutor.executeTools(tools, assistantMessage.toolCalls()); + final OpenAiAssistantMessage assistantMessage = response.getMessage(); + final var toolResults = OpenAiTool.execute(tools, assistantMessage); // 4. Send back the results for model will incorporate them into its final response. messages.add(assistantMessage); - messages.addAll(toolMessages); + messages.addAll(toolResults.getMessages()); - return OpenAiClient.forModel(GPT_4O_MINI).chatCompletion(request.withMessages(messages)); + return client.chatCompletion(request.withMessages(messages)); } /** From f297fedd070e25e0ed00d54a862c98bed3dd4d64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20D=C3=BCmont?= <22489773+newtork@users.noreply.github.com> Date: Wed, 30 Apr 2025 12:35:19 +0200 Subject: [PATCH 25/36] Update sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OpenAiServiceV2.java Co-authored-by: Jonas-Isr --- .../main/java/com/sap/ai/sdk/app/services/OpenAiServiceV2.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OpenAiServiceV2.java b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OpenAiServiceV2.java index 5890087b0..00f463d2b 100644 --- a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OpenAiServiceV2.java +++ b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OpenAiServiceV2.java @@ -113,7 +113,7 @@ public OpenAiChatCompletionResponse chatCompletionToolExecution( final OpenAiAssistantMessage assistantMessage = response.getMessage(); final var toolResults = OpenAiTool.execute(tools, assistantMessage); - // 4. Send back the results for model will incorporate them into its final response. + // 4. Return the results so that the model can incorporate them into the final response. messages.add(assistantMessage); messages.addAll(toolResults.getMessages()); From 9f96e1a5ef826a2688a70fbda2da4ce203db643c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20D=C3=BCmont?= Date: Wed, 30 Apr 2025 16:37:33 +0200 Subject: [PATCH 26/36] Remove generic type argument from OpenAiTool class; Migrate error-prone constructor to builder pattern that enforces required values --- .../openai/OpenAiChatCompletionRequest.java | 2 +- .../foundationmodels/openai/OpenAiTool.java | 106 ++++++++++++------ .../OpenAiChatCompletionRequestTest.java | 16 ++- .../openai/OpenAiToolTest.java | 37 +++--- .../ai/sdk/app/services/OpenAiServiceV2.java | 9 +- 5 files changed, 105 insertions(+), 65 deletions(-) diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequest.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequest.java index e0235f64e..3518a3f3e 100644 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequest.java +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequest.java @@ -292,7 +292,7 @@ public OpenAiChatCompletionRequest withToolChoice(@Nonnull final OpenAiToolChoic */ @Nonnull @Beta - public OpenAiChatCompletionRequest withToolsExecutable(@Nonnull final List> tools) { + public OpenAiChatCompletionRequest withToolsExecutable(@Nonnull final List tools) { return this.withTools(tools.stream().map(OpenAiTool::createChatCompletionTool).toList()); } diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiTool.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiTool.java index 2039a7002..a4ab44183 100644 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiTool.java +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiTool.java @@ -6,6 +6,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; import com.github.victools.jsonschema.generator.Option; import com.github.victools.jsonschema.generator.OptionPreset; import com.github.victools.jsonschema.generator.SchemaGenerator; @@ -26,10 +27,11 @@ import javax.annotation.Nullable; import lombok.AccessLevel; import lombok.AllArgsConstructor; -import lombok.Data; import lombok.Getter; import lombok.RequiredArgsConstructor; -import lombok.experimental.Accessors; +import lombok.Setter; +import lombok.Value; +import lombok.With; import lombok.extern.slf4j.Slf4j; /** @@ -37,17 +39,16 @@ * request. This tool generates a JSON schema based on the provided class representing the * function's request structure. * - * @param the type of the input argument for the function * @see OpenAI Function * @since 1.7.0 */ @Slf4j @Beta -@Data +@Value +@With @Getter(AccessLevel.PACKAGE) -@Accessors(chain = true) @AllArgsConstructor(access = AccessLevel.PRIVATE) -public class OpenAiTool { +public class OpenAiTool { private static final ObjectMapper JACKSON = new ObjectMapper(); @@ -55,45 +56,85 @@ public class OpenAiTool { @Nonnull private static final SchemaGenerator GENERATOR = createSchemaGenerator(); /** The name of the function. */ - @Nonnull private String name; + @Setter(AccessLevel.NONE) + @Nonnull + String name; + + /** The function to execute a string argument to tool result object. */ + @Setter(AccessLevel.NONE) + @Nonnull + Function functionExecutor; - /** The model class for function request. */ - @Nonnull private Class requestClass; + /** schema to be used for the function call. */ + @Setter(AccessLevel.NONE) + @Nonnull + ObjectNode schema; /** An optional description of the function. */ - @Nullable private String description; + @Nullable String description; /** An optional flag indicating whether the function parameters should be treated strictly. */ - @Nullable private Boolean strict; + @Nullable Boolean strict; - /** The function to be called. */ - @Nullable private Function function; + /** + * Instantiates a OpenAiTool builder instance on behalf of an executable function. + * + * @param function the function to be executed. + * @return an OpenAiTool builder instance. + * @param the type of the function input-argument class. + */ + @Nonnull + public static Builder1 forFunction(@Nonnull final Function function) { + return inputClass -> + name -> { + final Function exec = + s -> function.apply(deserializeArgument(inputClass, s)); + final var schema = GENERATOR.generateSchema(inputClass); + return new OpenAiTool(name, exec, schema, null, null); + }; + } /** - * Constructs an {@code OpenAiFunctionTool} with the specified name and a model class that - * captures the request to the function. + * Creates a new OpenAiTool instance with the specified function and input class. * - * @param name the name of the function - * @param requestClass the model class for function request + * @param the type of the input class. */ - public OpenAiTool(@Nonnull final String name, @Nonnull final Class requestClass) { - this(name, requestClass, null, null, null); + public interface Builder1 { + /** + * Sets the name of the function. + * + * @param inputClass the class of the input object. + * @return a new OpenAiTool instance with the specified function and input class. + */ + @Nonnull + Builder2 withArgument(@Nonnull final Class inputClass); } - @Nonnull - Object execute(@Nonnull final InputT argument) { - if (getFunction() == null) { - throw new IllegalStateException( - "Tool " + name + " is missing a method reference to execute."); + /** Creates a new OpenAiTool instance with the specified name. */ + public interface Builder2 { + /** + * Sets the name of the function. + * + * @param name the name of the function + * @return a new OpenAiTool instance with the specified name + */ + @Nonnull + OpenAiTool withName(@Nonnull final String name); + } + + @Nullable + private static T deserializeArgument(@Nonnull final Class cl, @Nonnull final String s) { + try { + return JACKSON.readValue(s, cl); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException("Failed to parse JSON string to class " + cl, e); } - return getFunction().apply(argument); } ChatCompletionTool createChatCompletionTool() { - final var schema = GENERATOR.generateSchema(getRequestClass()); final var schemaMap = OpenAiUtils.getOpenAiObjectMapper() - .convertValue(schema, new TypeReference>() {}); + .convertValue(getSchema(), new TypeReference>() {}); return new ChatCompletionTool() .type(FUNCTION) @@ -128,7 +169,7 @@ private static SchemaGenerator createSchemaGenerator() { @Beta @Nonnull public static Execution execute( - @Nonnull final List> tools, @Nonnull final OpenAiAssistantMessage msg) + @Nonnull final List tools, @Nonnull final OpenAiAssistantMessage msg) throws IllegalArgumentException { final var result = new LinkedHashMap(); @@ -148,10 +189,11 @@ public static Execution execute( } @Nonnull - private static Object executeFunction( - @Nonnull final OpenAiTool tool, @Nonnull final OpenAiFunctionCall toolCall) { - final I arguments = toolCall.getArgumentsAsObject(tool.getRequestClass()); - return tool.execute(arguments); + private static Object executeFunction( + @Nonnull final OpenAiTool tool, @Nonnull final OpenAiFunctionCall toolCall) { + final Function executor = tool.getFunctionExecutor(); + final String arguments = toolCall.getArguments(); + return executor.apply(arguments); } @Nonnull diff --git a/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequestTest.java b/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequestTest.java index 6d110bd80..88fcebae4 100644 --- a/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequestTest.java +++ b/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequestTest.java @@ -125,12 +125,16 @@ record DummyRequest(String param1, int param2) {} new OpenAiChatCompletionRequest(OpenAiMessage.user("Hello, world")) .withToolsExecutable( List.of( - new OpenAiTool<>("toolA", DummyRequest.class) - .setDescription("descA") - .setStrict(true), - new OpenAiTool<>("toolB", DummyRequest.class) - .setDescription("descB") - .setStrict(false))); + OpenAiTool.forFunction(r -> "result") + .withArgument(DummyRequest.class) + .withName("toolA") + .withDescription("descA") + .withStrict(true), + OpenAiTool.forFunction(r -> "result") + .withArgument(DummyRequest.class) + .withName("toolB") + .withDescription("descB") + .withStrict(true))); var lowLevelRequest = request.createCreateChatCompletionRequest(); assertThat(lowLevelRequest.getTools()).hasSize(2); diff --git a/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiToolTest.java b/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiToolTest.java index fa4c55879..5cbc9961a 100644 --- a/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiToolTest.java +++ b/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiToolTest.java @@ -8,7 +8,6 @@ import java.util.Map; import java.util.function.Function; import lombok.EqualsAndHashCode; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; class OpenAiToolTest { @@ -31,13 +30,6 @@ record Response(String toolMsg) {} request -> new Dummy.Response(request.key()); } - private OpenAiTool toolA; - - @BeforeEach - void setUp() { - toolA = new OpenAiTool<>("functionA", Dummy.Request.class); - } - @Test void getArgumentsAsMapValid() { final var result = FUNCTION_CALL_A.getArgumentsAsMap(); @@ -53,21 +45,24 @@ void getArgumentsAsMapInvalid() { @Test void getArgumentsAsObjectValid() { - final Dummy.Request result = FUNCTION_CALL_A.getArgumentsAsObject(toolA.getRequestClass()); + final Dummy.Request result = FUNCTION_CALL_A.getArgumentsAsObject(Dummy.Request.class); assertThat(result).isInstanceOf(Dummy.Request.class); assertThat(result.key()).isEqualTo("value"); } @Test void getArgumentsAsObjectInvalid() { - assertThatThrownBy(() -> INVALID_FUNCTION_CALL_A.getArgumentsAsObject(toolA.getRequestClass())) + assertThatThrownBy(() -> INVALID_FUNCTION_CALL_A.getArgumentsAsObject(Integer.class)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("Failed to parse JSON string"); } @Test void executeToolsValid() { - toolA.setFunction(Dummy.conCat); + final var toolA = + OpenAiTool.forFunction(Dummy.conCat) + .withArgument(Dummy.Request.class) + .withName("functionA"); final var assistMsg = new OpenAiAssistantMessage(EMPTY_MSG_CONTENT, List.of(FUNCTION_CALL_A)); final var execution = OpenAiTool.execute(List.of(toolA), assistMsg); @@ -83,19 +78,14 @@ void executeToolsValid() { .isEqualTo("{\"toolMsg\":\"value\"}"); } - @Test - void executeToolsThrowsOnNoFunction() { - final var assistMsg = new OpenAiAssistantMessage(EMPTY_MSG_CONTENT, List.of(FUNCTION_CALL_A)); - assertThatThrownBy(() -> OpenAiTool.execute(List.of(toolA), assistMsg)) - .isInstanceOf(IllegalStateException.class) - .hasMessageContaining("Tool functionA is missing a method reference to execute."); - } - @Test void executeToolsNoMatchingCall() { - final var toolAWithFunction = toolA.setFunction(Dummy.conCat); + final var toolA = + OpenAiTool.forFunction(Dummy.conCat) + .withArgument(Dummy.Request.class) + .withName("functionA"); final var assistMsg = new OpenAiAssistantMessage(EMPTY_MSG_CONTENT, List.of(FUNCTION_CALL_B)); - final var executions = OpenAiTool.execute(List.of(toolAWithFunction), assistMsg); + final var executions = OpenAiTool.execute(List.of(toolA), assistMsg); assertThat(executions.getResults()).isEmpty(); assertThat(executions.getMessages()).isEmpty(); } @@ -111,7 +101,10 @@ class NonSerializableResponse { } } - toolA.setFunction(request -> new NonSerializableResponse(request.key())); + final Function badF = + request -> new NonSerializableResponse(request.key()); + final var toolA = + OpenAiTool.forFunction(badF).withArgument(Dummy.Request.class).withName("functionA"); final var assistMsg = new OpenAiAssistantMessage(EMPTY_MSG_CONTENT, List.of(FUNCTION_CALL_A)); final var executions = OpenAiTool.execute(List.of(toolA), assistMsg); diff --git a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OpenAiServiceV2.java b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OpenAiServiceV2.java index 00f463d2b..560068e27 100644 --- a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OpenAiServiceV2.java +++ b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OpenAiServiceV2.java @@ -99,11 +99,12 @@ public OpenAiChatCompletionResponse chatCompletionToolExecution( messages.add(OpenAiMessage.user("What's the weather in %s in %s?".formatted(location, unit))); // 1. Define the function - final List> tools = + final List tools = List.of( - new OpenAiTool<>("weather", WeatherMethod.Request.class) - .setDescription("Get the weather for the given location") - .setFunction(WeatherMethod::getCurrentWeather)); + OpenAiTool.forFunction(WeatherMethod::getCurrentWeather) + .withArgument(WeatherMethod.Request.class) + .withName("weather") + .withDescription("Get the weather for the given location")); // 2. Assistant calls the function final var request = new OpenAiChatCompletionRequest(messages).withToolsExecutable(tools); From ce3ba95c5e3d0a3417e95dd75f49b3a006b359bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20D=C3=BCmont?= Date: Wed, 30 Apr 2025 16:53:32 +0200 Subject: [PATCH 27/36] Remove unnecessary throws declaration --- .../com/sap/ai/sdk/foundationmodels/openai/OpenAiTool.java | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiTool.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiTool.java index a4ab44183..aefeeaa5e 100644 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiTool.java +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiTool.java @@ -164,13 +164,11 @@ private static SchemaGenerator createSchemaGenerator() { * @param tools the list of tools to execute * @param msg the assistant message containing a list of tool calls with arguments * @return a result object that contains the list of tool messages with the results - * @throws IllegalStateException if a tool is missing a method reference for function execution. */ @Beta @Nonnull public static Execution execute( - @Nonnull final List tools, @Nonnull final OpenAiAssistantMessage msg) - throws IllegalArgumentException { + @Nonnull final List tools, @Nonnull final OpenAiAssistantMessage msg) { final var result = new LinkedHashMap(); final var toolMap = tools.stream().collect(Collectors.toMap(OpenAiTool::getName, identity())); From 3667b1eea08781861a3d1a2389dca86d6c65e95a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20D=C3=BCmont?= Date: Wed, 30 Apr 2025 16:59:35 +0200 Subject: [PATCH 28/36] Reverted the intermediate result class; `OpenAiTool#execute` will directly return the tool message list --- .../foundationmodels/openai/OpenAiTool.java | 56 ++++++++----------- .../openai/OpenAiToolTest.java | 26 +++------ .../ai/sdk/app/services/OpenAiServiceV2.java | 5 +- 3 files changed, 34 insertions(+), 53 deletions(-) diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiTool.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiTool.java index aefeeaa5e..e99355819 100644 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiTool.java +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiTool.java @@ -28,7 +28,6 @@ import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Getter; -import lombok.RequiredArgsConstructor; import lombok.Setter; import lombok.Value; import lombok.With; @@ -163,14 +162,34 @@ private static SchemaGenerator createSchemaGenerator() { * * @param tools the list of tools to execute * @param msg the assistant message containing a list of tool calls with arguments - * @return a result object that contains the list of tool messages with the results + * @return The list of tool messages with the results. */ @Beta @Nonnull - public static Execution execute( + public static List execute( @Nonnull final List tools, @Nonnull final OpenAiAssistantMessage msg) { - final var result = new LinkedHashMap(); + final var toolResults = executeInternal(tools, msg); + final var result = new ArrayList(); + for (final var entry : toolResults.entrySet()) { + final var functionCall = entry.getKey().getId(); + final var serializedValue = serializeObject(entry.getValue()); + result.add(OpenAiMessage.tool(serializedValue, functionCall)); + } + return result; + } + /** + * Executes the given tool calls with the provided tools and returns the results as a list of + * {@link OpenAiToolMessage} containing execution results encoded as JSON string. + * + * @param tools the list of tools to execute + * @param msg the assistant message containing a list of tool calls with arguments + * @return a map that contains the function calls and their respective tool results. + */ + @Nonnull + protected static Map executeInternal( + @Nonnull final List tools, @Nonnull final OpenAiAssistantMessage msg) { + final var result = new LinkedHashMap(); final var toolMap = tools.stream().collect(Collectors.toMap(OpenAiTool::getName, identity())); for (final OpenAiToolCall toolCall : msg.toolCalls()) { if (toolCall instanceof OpenAiFunctionCall functionCall) { @@ -183,7 +202,7 @@ public static Execution execute( result.put(functionCall, toolResult); } } - return new Execution(result); + return result; } @Nonnull @@ -202,31 +221,4 @@ private static String serializeObject(@Nonnull final Object obj) throws IllegalA throw new IllegalArgumentException("Failed to serialize object to JSON", e); } } - - /** - * Represents the result of executing a tool call, containing the results of the function calls. - */ - @RequiredArgsConstructor - @Beta - public static class Execution { - @Getter @Beta @Nonnull private final Map results; - - /** - * Creates a new list of serialized OpenAI tool messages. - * - * @return the list of serialized OpenAI tool messages. - * @throws IllegalArgumentException if the tool results cannot be serialized to JSON - */ - @Beta - @Nonnull - public List getMessages() { - final var result = new ArrayList(); - for (final var entry : getResults().entrySet()) { - final var functionCall = entry.getKey().getId(); - final var serializedValue = serializeObject(entry.getValue()); - result.add(OpenAiMessage.tool(serializedValue, functionCall)); - } - return result; - } - } } diff --git a/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiToolTest.java b/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiToolTest.java index 5cbc9961a..fcdc3827c 100644 --- a/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiToolTest.java +++ b/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiToolTest.java @@ -5,7 +5,6 @@ import java.util.Collections; import java.util.List; -import java.util.Map; import java.util.function.Function; import lombok.EqualsAndHashCode; import org.junit.jupiter.api.Test; @@ -64,17 +63,11 @@ void executeToolsValid() { .withArgument(Dummy.Request.class) .withName("functionA"); final var assistMsg = new OpenAiAssistantMessage(EMPTY_MSG_CONTENT, List.of(FUNCTION_CALL_A)); - final var execution = OpenAiTool.execute(List.of(toolA), assistMsg); + final var toolMsgs = OpenAiTool.execute(List.of(toolA), assistMsg); - final var results = execution.getResults(); - assertThat(results) - .hasSize(1) - .containsExactly(Map.entry(FUNCTION_CALL_A, new Dummy.Response("value"))); - - final var toolMsg = execution.getMessages(); - assertThat(toolMsg).hasSize(1); - assertThat(toolMsg.get(0).toolCallId()).isEqualTo("1"); - assertThat(((OpenAiTextItem) toolMsg.get(0).content().items().get(0)).text()) + assertThat(toolMsgs).hasSize(1); + assertThat(toolMsgs.get(0).toolCallId()).isEqualTo("1"); + assertThat(((OpenAiTextItem) toolMsgs.get(0).content().items().get(0)).text()) .isEqualTo("{\"toolMsg\":\"value\"}"); } @@ -85,9 +78,8 @@ void executeToolsNoMatchingCall() { .withArgument(Dummy.Request.class) .withName("functionA"); final var assistMsg = new OpenAiAssistantMessage(EMPTY_MSG_CONTENT, List.of(FUNCTION_CALL_B)); - final var executions = OpenAiTool.execute(List.of(toolA), assistMsg); - assertThat(executions.getResults()).isEmpty(); - assertThat(executions.getMessages()).isEmpty(); + final var toolMsgs = OpenAiTool.execute(List.of(toolA), assistMsg); + assertThat(toolMsgs).isEmpty(); } @Test @@ -106,12 +98,8 @@ class NonSerializableResponse { final var toolA = OpenAiTool.forFunction(badF).withArgument(Dummy.Request.class).withName("functionA"); final var assistMsg = new OpenAiAssistantMessage(EMPTY_MSG_CONTENT, List.of(FUNCTION_CALL_A)); - final var executions = OpenAiTool.execute(List.of(toolA), assistMsg); - - assertThat(executions.getResults()) - .containsExactly(Map.entry(FUNCTION_CALL_A, new NonSerializableResponse("value"))); - assertThatThrownBy(executions::getMessages) + assertThatThrownBy(() -> OpenAiTool.execute(List.of(toolA), assistMsg)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("Failed to serialize object to JSON"); } diff --git a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OpenAiServiceV2.java b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OpenAiServiceV2.java index 560068e27..34bfd980e 100644 --- a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OpenAiServiceV2.java +++ b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OpenAiServiceV2.java @@ -15,6 +15,7 @@ import com.sap.ai.sdk.foundationmodels.openai.OpenAiImageItem; import com.sap.ai.sdk.foundationmodels.openai.OpenAiMessage; import com.sap.ai.sdk.foundationmodels.openai.OpenAiTool; +import com.sap.ai.sdk.foundationmodels.openai.OpenAiToolMessage; import java.util.ArrayList; import java.util.List; import java.util.stream.Stream; @@ -112,11 +113,11 @@ public OpenAiChatCompletionResponse chatCompletionToolExecution( // 3. Execute the tool call for given tools final OpenAiAssistantMessage assistantMessage = response.getMessage(); - final var toolResults = OpenAiTool.execute(tools, assistantMessage); + final List toolMessages = OpenAiTool.execute(tools, assistantMessage); // 4. Return the results so that the model can incorporate them into the final response. messages.add(assistantMessage); - messages.addAll(toolResults.getMessages()); + messages.addAll(toolMessages); return client.chatCompletion(request.withMessages(messages)); } From 454f7c593a0e511544617bb2ccfb482c646d8e1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20D=C3=BCmont?= Date: Wed, 14 May 2025 10:52:08 +0200 Subject: [PATCH 29/36] Remove unused code --- .../openai/OpenAiFunctionCall.java | 35 ------------------- .../foundationmodels/openai/OpenAiTool.java | 2 +- .../openai/OpenAiToolTest.java | 11 +++--- 3 files changed, 8 insertions(+), 40 deletions(-) diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionCall.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionCall.java index 9aee03190..c3668d26b 100644 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionCall.java +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiFunctionCall.java @@ -1,10 +1,6 @@ package com.sap.ai.sdk.foundationmodels.openai; -import static com.sap.ai.sdk.foundationmodels.openai.OpenAiUtils.getOpenAiObjectMapper; - -import com.fasterxml.jackson.core.JsonProcessingException; import com.google.common.annotations.Beta; -import java.util.Map; import javax.annotation.Nonnull; import lombok.AllArgsConstructor; import lombok.Value; @@ -26,35 +22,4 @@ public class OpenAiFunctionCall implements OpenAiToolCall { /** The arguments for the function call, encoded as a JSON string. */ @Nonnull String arguments; - - /** - * Parses the arguments, encoded as a JSON string, into a {@code Map}. - * - * @return a map of the arguments - * @throws IllegalArgumentException if parsing fails - * @since 1.7.0 - */ - @Nonnull - public Map getArgumentsAsMap() throws IllegalArgumentException { - return getArgumentsAsObject(Map.class); - } - - /** - * Parses the arguments, encoded as a JSON string, into an object of type expected by a function - * tool. - * - * @param type the class reference for requested type. - * @param the type of the class - * @return the parsed arguments as an object - * @throws IllegalArgumentException if parsing fails - * @since 1.7.0 - */ - @Nonnull - public T getArgumentsAsObject(@Nonnull final Class type) throws IllegalArgumentException { - try { - return getOpenAiObjectMapper().readValue(getArguments(), type); - } catch (JsonProcessingException e) { - throw new IllegalArgumentException("Failed to parse JSON string to class " + type, e); - } - } } diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiTool.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiTool.java index e99355819..a000efd56 100644 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiTool.java +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiTool.java @@ -122,7 +122,7 @@ public interface Builder2 { } @Nullable - private static T deserializeArgument(@Nonnull final Class cl, @Nonnull final String s) { + static T deserializeArgument(@Nonnull final Class cl, @Nonnull final String s) { try { return JACKSON.readValue(s, cl); } catch (JsonProcessingException e) { diff --git a/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiToolTest.java b/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiToolTest.java index fcdc3827c..41c911e3f 100644 --- a/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiToolTest.java +++ b/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiToolTest.java @@ -1,10 +1,12 @@ package com.sap.ai.sdk.foundationmodels.openai; +import static com.sap.ai.sdk.foundationmodels.openai.OpenAiTool.deserializeArgument; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import java.util.Collections; import java.util.List; +import java.util.Map; import java.util.function.Function; import lombok.EqualsAndHashCode; import org.junit.jupiter.api.Test; @@ -31,27 +33,28 @@ record Response(String toolMsg) {} @Test void getArgumentsAsMapValid() { - final var result = FUNCTION_CALL_A.getArgumentsAsMap(); + final var result = deserializeArgument(Map.class, FUNCTION_CALL_A.getArguments()); assertThat(result).containsEntry("key", "value"); } @Test void getArgumentsAsMapInvalid() { - assertThatThrownBy(INVALID_FUNCTION_CALL_A::getArgumentsAsMap) + assertThatThrownBy(() -> deserializeArgument(Map.class, INVALID_FUNCTION_CALL_A.getArguments())) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("Failed to parse JSON string"); } @Test void getArgumentsAsObjectValid() { - final Dummy.Request result = FUNCTION_CALL_A.getArgumentsAsObject(Dummy.Request.class); + final var result = deserializeArgument(Dummy.Request.class, FUNCTION_CALL_A.getArguments()); assertThat(result).isInstanceOf(Dummy.Request.class); assertThat(result.key()).isEqualTo("value"); } @Test void getArgumentsAsObjectInvalid() { - assertThatThrownBy(() -> INVALID_FUNCTION_CALL_A.getArgumentsAsObject(Integer.class)) + final var payload = INVALID_FUNCTION_CALL_A.getArguments(); + assertThatThrownBy(() -> deserializeArgument(Integer.class, payload)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("Failed to parse JSON string"); } From ed01c2236862328917bdd77eee3697376e64cf6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20D=C3=BCmont?= Date: Wed, 14 May 2025 13:12:26 +0200 Subject: [PATCH 30/36] Add convenience method OpenAiChatCompletionResponse#executeTools --- .../openai/OpenAiChatCompletionRequest.java | 53 +++++++++++-------- .../openai/OpenAiChatCompletionResponse.java | 13 +++++ .../foundationmodels/openai/OpenAiClient.java | 4 +- .../ai/sdk/app/services/OpenAiServiceV2.java | 11 ++-- 4 files changed, 51 insertions(+), 30 deletions(-) diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequest.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequest.java index 3518a3f3e..853f940a9 100644 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequest.java +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequest.java @@ -9,6 +9,7 @@ import com.sap.ai.sdk.foundationmodels.openai.generated.model.CreateChatCompletionRequestAllOfResponseFormat; import com.sap.ai.sdk.foundationmodels.openai.generated.model.CreateChatCompletionRequestAllOfStop; import java.math.BigDecimal; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Objects; @@ -125,6 +126,15 @@ public class OpenAiChatCompletionRequest { /** List of tools that the model may invoke during the completion. */ @Nullable List tools; + /** + * List of tools that are executable at runtime of the application. + * + * @since 1.7.0 + */ + @Getter(value = AccessLevel.PACKAGE) + @Nullable + List toolsExecutable; + /** Option to control which tool is invoked by the model. */ @With(AccessLevel.PRIVATE) @Nullable @@ -179,6 +189,7 @@ public OpenAiChatCompletionRequest(@Nonnull final List messages) null, null, null, + null, null); } @@ -226,6 +237,7 @@ public OpenAiChatCompletionRequest withParallelToolCalls( this.streamOptions, this.responseFormat, this.tools, + this.toolsExecutable, this.toolChoice); } @@ -258,6 +270,7 @@ public OpenAiChatCompletionRequest withLogprobs(@Nonnull final Boolean logprobs) this.streamOptions, this.responseFormat, this.tools, + this.toolsExecutable, this.toolChoice); } @@ -282,36 +295,35 @@ public OpenAiChatCompletionRequest withToolChoice(@Nonnull final OpenAiToolChoic return this.withToolChoice(choice.toolChoice); } - /** - * Sets the tools to be used in the request with convenience class {@code OpenAiTool}. - * - * @param tools the list of tools to be used - * @return a new OpenAiChatCompletionRequest instance with the specified tools - * @throws IllegalArgumentException if the tool type is not supported - * @since 1.7.0 - */ - @Nonnull - @Beta - public OpenAiChatCompletionRequest withToolsExecutable(@Nonnull final List tools) { - return this.withTools(tools.stream().map(OpenAiTool::createChatCompletionTool).toList()); - } - /** * Converts the request to a generated model class CreateChatCompletionRequest. * * @return the CreateChatCompletionRequest */ CreateChatCompletionRequest createCreateChatCompletionRequest() { - final var request = new CreateChatCompletionRequest(); - this.messages.forEach( - message -> - request.addMessagesItem(OpenAiUtils.createChatCompletionRequestMessage(message))); + final var toolsCombined = new ArrayList(); + if (this.tools != null) { + toolsCombined.addAll(this.tools); + } + if (this.toolsExecutable != null) { + for (OpenAiTool tool : this.toolsExecutable) { + toolsCombined.add(tool.createChatCompletionTool()); + } + } - request.stop(this.stop != null ? CreateChatCompletionRequestAllOfStop.create(this.stop) : null); + final var request = new CreateChatCompletionRequest(); + for (OpenAiMessage message : this.messages) { + request.addMessagesItem(OpenAiUtils.createChatCompletionRequestMessage(message)); + } + if (this.stop != null) { + request.stop(CreateChatCompletionRequestAllOfStop.create(this.stop)); + } + if (!toolsCombined.isEmpty()) { + request.tools(toolsCombined); + } request.temperature(this.temperature); request.topP(this.topP); - request.stream(null); request.maxTokens(this.maxTokens); request.maxCompletionTokens(this.maxCompletionTokens); @@ -326,7 +338,6 @@ CreateChatCompletionRequest createCreateChatCompletionRequest() { request.seed(this.seed); request.streamOptions(this.streamOptions); request.responseFormat(this.responseFormat); - request.tools(this.tools); request.toolChoice(this.toolChoice); request.functionCall(null); request.functions(null); 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 7bcf73854..9d00af5e7 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 @@ -28,6 +28,9 @@ public class OpenAiChatCompletionResponse { /** The original response from the OpenAI API. */ @Nonnull CreateChatCompletionResponse originalResponse; + /** The original request that was sent to the OpenAI API. */ + @Nonnull OpenAiChatCompletionRequest originalRequest; + /** * Gets the token usage from the original response. * @@ -96,4 +99,14 @@ public OpenAiAssistantMessage getMessage() { return new OpenAiAssistantMessage(new OpenAiMessageContent(contentItems), openAiToolCalls); } + + /** + * Execute tool calls that were suggested by the assistant response. + * + * @return the list of tool messages that were serialized for the computed results. Empty list if + * no tools were called. + */ + public List executeTools() { + return OpenAiTool.execute(getOriginalRequest().getToolsExecutable(), 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 7cfcadb35..88f8513a0 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 @@ -158,8 +158,8 @@ public OpenAiChatCompletionOutput chatCompletion(@Nonnull final String prompt) public OpenAiChatCompletionResponse chatCompletion( @Nonnull final OpenAiChatCompletionRequest request) throws OpenAiClientException { warnIfUnsupportedUsage(); - return new OpenAiChatCompletionResponse( - chatCompletion(request.createCreateChatCompletionRequest())); + final var response = chatCompletion(request.createCreateChatCompletionRequest()); + return new OpenAiChatCompletionResponse(response, request); } /** diff --git a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OpenAiServiceV2.java b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OpenAiServiceV2.java index 34bfd980e..35bfe83d9 100644 --- a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OpenAiServiceV2.java +++ b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OpenAiServiceV2.java @@ -111,14 +111,11 @@ public OpenAiChatCompletionResponse chatCompletionToolExecution( final var request = new OpenAiChatCompletionRequest(messages).withToolsExecutable(tools); final OpenAiChatCompletionResponse response = client.chatCompletion(request); - // 3. Execute the tool call for given tools - final OpenAiAssistantMessage assistantMessage = response.getMessage(); - final List toolMessages = OpenAiTool.execute(tools, assistantMessage); - - // 4. Return the results so that the model can incorporate them into the final response. - messages.add(assistantMessage); - messages.addAll(toolMessages); + // 3. Execute the tool calls + messages.add(response.getMessage()); + messages.addAll(response.executeTools()); + // 4. Have model run the final request with incorporated tool results return client.chatCompletion(request.withMessages(messages)); } From 1f4d2a28c5db063289b9d4d96c0af4d4c7fa4612 Mon Sep 17 00:00:00 2001 From: SAP Cloud SDK Bot Date: Wed, 14 May 2025 11:13:05 +0000 Subject: [PATCH 31/36] Formatting --- .../main/java/com/sap/ai/sdk/app/services/OpenAiServiceV2.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OpenAiServiceV2.java b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OpenAiServiceV2.java index 35bfe83d9..0502cafad 100644 --- a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OpenAiServiceV2.java +++ b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OpenAiServiceV2.java @@ -5,7 +5,6 @@ import static com.sap.ai.sdk.foundationmodels.openai.OpenAiModel.TEXT_EMBEDDING_3_SMALL; import com.sap.ai.sdk.core.AiCoreService; -import com.sap.ai.sdk.foundationmodels.openai.OpenAiAssistantMessage; import com.sap.ai.sdk.foundationmodels.openai.OpenAiChatCompletionDelta; import com.sap.ai.sdk.foundationmodels.openai.OpenAiChatCompletionRequest; import com.sap.ai.sdk.foundationmodels.openai.OpenAiChatCompletionResponse; @@ -15,7 +14,6 @@ import com.sap.ai.sdk.foundationmodels.openai.OpenAiImageItem; import com.sap.ai.sdk.foundationmodels.openai.OpenAiMessage; import com.sap.ai.sdk.foundationmodels.openai.OpenAiTool; -import com.sap.ai.sdk.foundationmodels.openai.OpenAiToolMessage; import java.util.ArrayList; import java.util.List; import java.util.stream.Stream; From 6bd64ff82e3d403848d705035e5151abb5f3f757 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20D=C3=BCmont?= Date: Wed, 14 May 2025 13:22:42 +0200 Subject: [PATCH 32/36] Hide getter --- .../openai/OpenAiChatCompletionResponse.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 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 9d00af5e7..dfad4f826 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 @@ -11,6 +11,7 @@ import java.util.List; import java.util.Objects; import javax.annotation.Nonnull; +import lombok.Getter; import lombok.RequiredArgsConstructor; import lombok.Setter; import lombok.Value; @@ -29,7 +30,9 @@ public class OpenAiChatCompletionResponse { @Nonnull CreateChatCompletionResponse originalResponse; /** The original request that was sent to the OpenAI API. */ - @Nonnull OpenAiChatCompletionRequest originalRequest; + @Getter(NONE) + @Nonnull + OpenAiChatCompletionRequest originalRequest; /** * Gets the token usage from the original response. @@ -107,6 +110,6 @@ public OpenAiAssistantMessage getMessage() { * no tools were called. */ public List executeTools() { - return OpenAiTool.execute(getOriginalRequest().getToolsExecutable(), getMessage()); + return OpenAiTool.execute(originalRequest.getToolsExecutable(), getMessage()); } } From ca7fc2565197771a5abb99236a5ebd940e4da4b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20D=C3=BCmont?= Date: Wed, 14 May 2025 13:34:02 +0200 Subject: [PATCH 33/36] Fix PMD --- .../foundationmodels/openai/OpenAiChatCompletionRequest.java | 4 ++-- .../foundationmodels/openai/OpenAiChatCompletionResponse.java | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequest.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequest.java index 853f940a9..564c2df41 100644 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequest.java +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequest.java @@ -306,13 +306,13 @@ CreateChatCompletionRequest createCreateChatCompletionRequest() { toolsCombined.addAll(this.tools); } if (this.toolsExecutable != null) { - for (OpenAiTool tool : this.toolsExecutable) { + for (final OpenAiTool tool : this.toolsExecutable) { toolsCombined.add(tool.createChatCompletionTool()); } } final var request = new CreateChatCompletionRequest(); - for (OpenAiMessage message : this.messages) { + for (final OpenAiMessage message : this.messages) { request.addMessagesItem(OpenAiUtils.createChatCompletionRequestMessage(message)); } if (this.stop != null) { 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 dfad4f826..35fdc3e5e 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 @@ -109,7 +109,9 @@ public OpenAiAssistantMessage getMessage() { * @return the list of tool messages that were serialized for the computed results. Empty list if * no tools were called. */ + @Nonnull public List executeTools() { - return OpenAiTool.execute(originalRequest.getToolsExecutable(), getMessage()); + final var tools = originalRequest.getToolsExecutable(); + return OpenAiTool.execute(tools != null ? tools : List.of(), getMessage()); } } From 3e6996734e1994604cacb8cd0569af825e037374 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20D=C3=BCmont?= Date: Wed, 14 May 2025 13:46:36 +0200 Subject: [PATCH 34/36] Fix tests --- .../openai/OpenAiChatCompletionRequest.java | 40 ++++++++++--------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequest.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequest.java index 564c2df41..427749495 100644 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequest.java +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequest.java @@ -301,29 +301,16 @@ public OpenAiChatCompletionRequest withToolChoice(@Nonnull final OpenAiToolChoic * @return the CreateChatCompletionRequest */ CreateChatCompletionRequest createCreateChatCompletionRequest() { - final var toolsCombined = new ArrayList(); - if (this.tools != null) { - toolsCombined.addAll(this.tools); - } - if (this.toolsExecutable != null) { - for (final OpenAiTool tool : this.toolsExecutable) { - toolsCombined.add(tool.createChatCompletionTool()); - } - } - final var request = new CreateChatCompletionRequest(); - for (final OpenAiMessage message : this.messages) { - request.addMessagesItem(OpenAiUtils.createChatCompletionRequestMessage(message)); - } - if (this.stop != null) { - request.stop(CreateChatCompletionRequestAllOfStop.create(this.stop)); - } - if (!toolsCombined.isEmpty()) { - request.tools(toolsCombined); - } + this.messages.forEach( + message -> + request.addMessagesItem(OpenAiUtils.createChatCompletionRequestMessage(message))); + + request.stop(this.stop != null ? CreateChatCompletionRequestAllOfStop.create(this.stop) : null); request.temperature(this.temperature); request.topP(this.topP); + request.stream(null); request.maxTokens(this.maxTokens); request.maxCompletionTokens(this.maxCompletionTokens); @@ -338,9 +325,24 @@ CreateChatCompletionRequest createCreateChatCompletionRequest() { request.seed(this.seed); request.streamOptions(this.streamOptions); request.responseFormat(this.responseFormat); + request.tools(getChatCompletionTools()); request.toolChoice(this.toolChoice); request.functionCall(null); request.functions(null); return request; } + + @Nullable + private List getChatCompletionTools() { + final var toolsCombined = new ArrayList(); + if (this.tools != null) { + toolsCombined.addAll(this.tools); + } + if (this.toolsExecutable != null) { + for (final OpenAiTool tool : this.toolsExecutable) { + toolsCombined.add(tool.createChatCompletionTool()); + } + } + return toolsCombined.isEmpty() ? null : toolsCombined; + } } From 8f2346d845fc80338233dc5b5253d466c16fe67e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20D=C3=BCmont?= Date: Wed, 14 May 2025 14:08:03 +0200 Subject: [PATCH 35/36] Make inaccessible method private --- .../java/com/sap/ai/sdk/foundationmodels/openai/OpenAiTool.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiTool.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiTool.java index a000efd56..654891b18 100644 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiTool.java +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiTool.java @@ -187,7 +187,7 @@ public static List execute( * @return a map that contains the function calls and their respective tool results. */ @Nonnull - protected static Map executeInternal( + private static Map executeInternal( @Nonnull final List tools, @Nonnull final OpenAiAssistantMessage msg) { final var result = new LinkedHashMap(); final var toolMap = tools.stream().collect(Collectors.toMap(OpenAiTool::getName, identity())); From c21ef7502b90765c680505f17e47c4cd6f4f1b99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20D=C3=BCmont?= Date: Wed, 14 May 2025 14:39:46 +0200 Subject: [PATCH 36/36] Reduce visibility of OpenAiTool#execute --- .../java/com/sap/ai/sdk/foundationmodels/openai/OpenAiTool.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiTool.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiTool.java index 654891b18..3d2e7e900 100644 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiTool.java +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiTool.java @@ -166,7 +166,7 @@ private static SchemaGenerator createSchemaGenerator() { */ @Beta @Nonnull - public static List execute( + static List execute( @Nonnull final List tools, @Nonnull final OpenAiAssistantMessage msg) { final var toolResults = executeInternal(tools, msg); final var result = new ArrayList();