Skip to content

Commit f21c732

Browse files
rpanackalCharlesDuboisSAPbot-sdk-jsa-dnewtork
authored
feat: [OpenAI] Tool Definition and Call Parsing V2 (Incl Execution) (#418)
* feat: add methods to convert function call arguments to Map and specified object type * feat: introduce OpenAiFunctionTool and enhance OpenAiChatCompletionRequest with tool handling * docs: enhance OpenAiFunctionTool and related classes documentation * docs: add @SInCE 1.7.0 annotations to relevant methods for version tracking * ci * fix unintentional javadoc change * test: update Javadoc to include exception details and enhance test coverage for OpenAiFunctionCall and OpenAiFunctionTool * refactor: enhance JSON parsing methods in OpenAiFunctionCall and remove redundant methods from OpenAiUtils * refactor: simplify JSON parsing in OpenAiFunctionCall and add unit tests for argument methods * refactor: update OpenAiFunctionTool to improve argument parsing and enhance type safety - OpenAiFunctionTool getter package private - introduce getArgumentsAsObject(OpenAiFunctionTool) * part done * finito * Formatting * With purely generics * With purely explicit types * refactor: enhance response serialization and improve schema generation error handling * Introduce OpenAiToolExecutor * CI * test: OpenAiToolExecutor and improve error messages * docs: update release notes to include tool execution in OpenAI functionality * fix: use the same schema generation dependency as in orchestration module * refactor: rename generic type parameter from I to InputT in OpenAiTool * fix shadowing problem * 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 * Update sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OpenAiServiceV2.java Co-authored-by: Jonas-Isr <[email protected]> * Remove generic type argument from OpenAiTool class; Migrate error-prone constructor to builder pattern that enforces required values * Remove unnecessary throws declaration * Reverted the intermediate result class; `OpenAiTool#execute` will directly return the tool message list * Remove unused code * Add convenience method OpenAiChatCompletionResponse#executeTools * Formatting * Hide getter * Fix PMD * Fix tests * Make inaccessible method private * Reduce visibility of OpenAiTool#execute --------- Co-authored-by: Roshin Rajan Panackal <[email protected]> Co-authored-by: I538344 <[email protected]> Co-authored-by: SAP Cloud SDK Bot <[email protected]> Co-authored-by: Alexander Dümont <[email protected]> Co-authored-by: Alexander Dümont <[email protected]> Co-authored-by: Jonas-Isr <[email protected]> Co-authored-by: Alexander Dümont <[email protected]>
1 parent ca85122 commit f21c732

File tree

9 files changed

+455
-71
lines changed

9 files changed

+455
-71
lines changed

docs/release_notes.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
### ✨ New Functionality
1414

15+
- [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)
1516
- [Orchestration] Added new model DeepSeek-R1: `OrchestrationAiModel.DEEPSEEK_R1`
1617
- [Orchestration] [Tool execution fully enabled](https://sap.github.io/ai-sdk/docs/java/spring-ai/orchestration#tool-calling)
1718

foundation-models/openai/pom.xml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,14 @@
7777
<groupId>com.fasterxml.jackson.core</groupId>
7878
<artifactId>jackson-annotations</artifactId>
7979
</dependency>
80+
<dependency>
81+
<groupId>com.github.victools</groupId>
82+
<artifactId>jsonschema-generator</artifactId>
83+
</dependency>
84+
<dependency>
85+
<groupId>com.github.victools</groupId>
86+
<artifactId>jsonschema-module-jackson</artifactId>
87+
</dependency>
8088
<dependency>
8189
<groupId>io.vavr</groupId>
8290
<artifactId>vavr</artifactId>

foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequest.java

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import com.sap.ai.sdk.foundationmodels.openai.generated.model.CreateChatCompletionRequestAllOfResponseFormat;
1010
import com.sap.ai.sdk.foundationmodels.openai.generated.model.CreateChatCompletionRequestAllOfStop;
1111
import java.math.BigDecimal;
12+
import java.util.ArrayList;
1213
import java.util.List;
1314
import java.util.Map;
1415
import java.util.Objects;
@@ -125,6 +126,15 @@ public class OpenAiChatCompletionRequest {
125126
/** List of tools that the model may invoke during the completion. */
126127
@Nullable List<ChatCompletionTool> tools;
127128

129+
/**
130+
* List of tools that are executable at runtime of the application.
131+
*
132+
* @since 1.7.0
133+
*/
134+
@Getter(value = AccessLevel.PACKAGE)
135+
@Nullable
136+
List<OpenAiTool> toolsExecutable;
137+
128138
/** Option to control which tool is invoked by the model. */
129139
@With(AccessLevel.PRIVATE)
130140
@Nullable
@@ -179,6 +189,7 @@ public OpenAiChatCompletionRequest(@Nonnull final List<OpenAiMessage> messages)
179189
null,
180190
null,
181191
null,
192+
null,
182193
null);
183194
}
184195

@@ -226,6 +237,7 @@ public OpenAiChatCompletionRequest withParallelToolCalls(
226237
this.streamOptions,
227238
this.responseFormat,
228239
this.tools,
240+
this.toolsExecutable,
229241
this.toolChoice);
230242
}
231243

@@ -258,6 +270,7 @@ public OpenAiChatCompletionRequest withLogprobs(@Nonnull final Boolean logprobs)
258270
this.streamOptions,
259271
this.responseFormat,
260272
this.tools,
273+
this.toolsExecutable,
261274
this.toolChoice);
262275
}
263276

@@ -312,10 +325,24 @@ CreateChatCompletionRequest createCreateChatCompletionRequest() {
312325
request.seed(this.seed);
313326
request.streamOptions(this.streamOptions);
314327
request.responseFormat(this.responseFormat);
315-
request.tools(this.tools);
328+
request.tools(getChatCompletionTools());
316329
request.toolChoice(this.toolChoice);
317330
request.functionCall(null);
318331
request.functions(null);
319332
return request;
320333
}
334+
335+
@Nullable
336+
private List<ChatCompletionTool> getChatCompletionTools() {
337+
final var toolsCombined = new ArrayList<ChatCompletionTool>();
338+
if (this.tools != null) {
339+
toolsCombined.addAll(this.tools);
340+
}
341+
if (this.toolsExecutable != null) {
342+
for (final OpenAiTool tool : this.toolsExecutable) {
343+
toolsCombined.add(tool.createChatCompletionTool());
344+
}
345+
}
346+
return toolsCombined.isEmpty() ? null : toolsCombined;
347+
}
321348
}

foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionResponse.java

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import java.util.List;
1212
import java.util.Objects;
1313
import javax.annotation.Nonnull;
14+
import lombok.Getter;
1415
import lombok.RequiredArgsConstructor;
1516
import lombok.Setter;
1617
import lombok.Value;
@@ -26,7 +27,12 @@
2627
@Setter(value = NONE)
2728
public class OpenAiChatCompletionResponse {
2829
/** The original response from the OpenAI API. */
29-
@Nonnull final CreateChatCompletionResponse originalResponse;
30+
@Nonnull CreateChatCompletionResponse originalResponse;
31+
32+
/** The original request that was sent to the OpenAI API. */
33+
@Getter(NONE)
34+
@Nonnull
35+
OpenAiChatCompletionRequest originalRequest;
3036

3137
/**
3238
* Gets the token usage from the original response.
@@ -96,4 +102,16 @@ public OpenAiAssistantMessage getMessage() {
96102

97103
return new OpenAiAssistantMessage(new OpenAiMessageContent(contentItems), openAiToolCalls);
98104
}
105+
106+
/**
107+
* Execute tool calls that were suggested by the assistant response.
108+
*
109+
* @return the list of tool messages that were serialized for the computed results. Empty list if
110+
* no tools were called.
111+
*/
112+
@Nonnull
113+
public List<OpenAiToolMessage> executeTools() {
114+
final var tools = originalRequest.getToolsExecutable();
115+
return OpenAiTool.execute(tools != null ? tools : List.of(), getMessage());
116+
}
99117
}

foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiClient.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,8 +158,8 @@ public OpenAiChatCompletionOutput chatCompletion(@Nonnull final String prompt)
158158
public OpenAiChatCompletionResponse chatCompletion(
159159
@Nonnull final OpenAiChatCompletionRequest request) throws OpenAiClientException {
160160
warnIfUnsupportedUsage();
161-
return new OpenAiChatCompletionResponse(
162-
chatCompletion(request.createCreateChatCompletionRequest()));
161+
final var response = chatCompletion(request.createCreateChatCompletionRequest());
162+
return new OpenAiChatCompletionResponse(response, request);
163163
}
164164

165165
/**
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
package com.sap.ai.sdk.foundationmodels.openai;
2+
3+
import static com.sap.ai.sdk.foundationmodels.openai.generated.model.ChatCompletionTool.TypeEnum.FUNCTION;
4+
import static java.util.function.UnaryOperator.identity;
5+
6+
import com.fasterxml.jackson.core.JsonProcessingException;
7+
import com.fasterxml.jackson.core.type.TypeReference;
8+
import com.fasterxml.jackson.databind.ObjectMapper;
9+
import com.fasterxml.jackson.databind.node.ObjectNode;
10+
import com.github.victools.jsonschema.generator.Option;
11+
import com.github.victools.jsonschema.generator.OptionPreset;
12+
import com.github.victools.jsonschema.generator.SchemaGenerator;
13+
import com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder;
14+
import com.github.victools.jsonschema.generator.SchemaVersion;
15+
import com.github.victools.jsonschema.module.jackson.JacksonModule;
16+
import com.github.victools.jsonschema.module.jackson.JacksonOption;
17+
import com.google.common.annotations.Beta;
18+
import com.sap.ai.sdk.foundationmodels.openai.generated.model.ChatCompletionTool;
19+
import com.sap.ai.sdk.foundationmodels.openai.generated.model.FunctionObject;
20+
import java.util.ArrayList;
21+
import java.util.LinkedHashMap;
22+
import java.util.List;
23+
import java.util.Map;
24+
import java.util.function.Function;
25+
import java.util.stream.Collectors;
26+
import javax.annotation.Nonnull;
27+
import javax.annotation.Nullable;
28+
import lombok.AccessLevel;
29+
import lombok.AllArgsConstructor;
30+
import lombok.Getter;
31+
import lombok.Setter;
32+
import lombok.Value;
33+
import lombok.With;
34+
import lombok.extern.slf4j.Slf4j;
35+
36+
/**
37+
* Represents an OpenAI tool that can be used to define a function call in an OpenAI Chat Completion
38+
* request. This tool generates a JSON schema based on the provided class representing the
39+
* function's request structure.
40+
*
41+
* @see <a href="https://platform.openai.com/docs/guides/gpt/function-calling"/>OpenAI Function
42+
* @since 1.7.0
43+
*/
44+
@Slf4j
45+
@Beta
46+
@Value
47+
@With
48+
@Getter(AccessLevel.PACKAGE)
49+
@AllArgsConstructor(access = AccessLevel.PRIVATE)
50+
public class OpenAiTool {
51+
52+
private static final ObjectMapper JACKSON = new ObjectMapper();
53+
54+
/** The schema generator used to create JSON schemas. */
55+
@Nonnull private static final SchemaGenerator GENERATOR = createSchemaGenerator();
56+
57+
/** The name of the function. */
58+
@Setter(AccessLevel.NONE)
59+
@Nonnull
60+
String name;
61+
62+
/** The function to execute a string argument to tool result object. */
63+
@Setter(AccessLevel.NONE)
64+
@Nonnull
65+
Function<String, Object> functionExecutor;
66+
67+
/** schema to be used for the function call. */
68+
@Setter(AccessLevel.NONE)
69+
@Nonnull
70+
ObjectNode schema;
71+
72+
/** An optional description of the function. */
73+
@Nullable String description;
74+
75+
/** An optional flag indicating whether the function parameters should be treated strictly. */
76+
@Nullable Boolean strict;
77+
78+
/**
79+
* Instantiates a OpenAiTool builder instance on behalf of an executable function.
80+
*
81+
* @param function the function to be executed.
82+
* @return an OpenAiTool builder instance.
83+
* @param <InputT> the type of the function input-argument class.
84+
*/
85+
@Nonnull
86+
public static <InputT> Builder1<InputT> forFunction(@Nonnull final Function<InputT, ?> function) {
87+
return inputClass ->
88+
name -> {
89+
final Function<String, Object> exec =
90+
s -> function.apply(deserializeArgument(inputClass, s));
91+
final var schema = GENERATOR.generateSchema(inputClass);
92+
return new OpenAiTool(name, exec, schema, null, null);
93+
};
94+
}
95+
96+
/**
97+
* Creates a new OpenAiTool instance with the specified function and input class.
98+
*
99+
* @param <InputT> the type of the input class.
100+
*/
101+
public interface Builder1<InputT> {
102+
/**
103+
* Sets the name of the function.
104+
*
105+
* @param inputClass the class of the input object.
106+
* @return a new OpenAiTool instance with the specified function and input class.
107+
*/
108+
@Nonnull
109+
Builder2 withArgument(@Nonnull final Class<InputT> inputClass);
110+
}
111+
112+
/** Creates a new OpenAiTool instance with the specified name. */
113+
public interface Builder2 {
114+
/**
115+
* Sets the name of the function.
116+
*
117+
* @param name the name of the function
118+
* @return a new OpenAiTool instance with the specified name
119+
*/
120+
@Nonnull
121+
OpenAiTool withName(@Nonnull final String name);
122+
}
123+
124+
@Nullable
125+
static <T> T deserializeArgument(@Nonnull final Class<T> cl, @Nonnull final String s) {
126+
try {
127+
return JACKSON.readValue(s, cl);
128+
} catch (JsonProcessingException e) {
129+
throw new IllegalArgumentException("Failed to parse JSON string to class " + cl, e);
130+
}
131+
}
132+
133+
ChatCompletionTool createChatCompletionTool() {
134+
final var schemaMap =
135+
OpenAiUtils.getOpenAiObjectMapper()
136+
.convertValue(getSchema(), new TypeReference<Map<String, Object>>() {});
137+
138+
return new ChatCompletionTool()
139+
.type(FUNCTION)
140+
.function(
141+
new FunctionObject()
142+
.name(getName())
143+
.description(getDescription())
144+
.parameters(schemaMap)
145+
.strict(getStrict()));
146+
}
147+
148+
private static SchemaGenerator createSchemaGenerator() {
149+
final var module =
150+
new JacksonModule(
151+
JacksonOption.RESPECT_JSONPROPERTY_REQUIRED, JacksonOption.RESPECT_JSONPROPERTY_ORDER);
152+
return new SchemaGenerator(
153+
new SchemaGeneratorConfigBuilder(SchemaVersion.DRAFT_2020_12, OptionPreset.PLAIN_JSON)
154+
.without(Option.SCHEMA_VERSION_INDICATOR)
155+
.with(module)
156+
.build());
157+
}
158+
159+
/**
160+
* Executes the given tool calls with the provided tools and returns the results as a list of
161+
* {@link OpenAiToolMessage} containing execution results encoded as JSON string.
162+
*
163+
* @param tools the list of tools to execute
164+
* @param msg the assistant message containing a list of tool calls with arguments
165+
* @return The list of tool messages with the results.
166+
*/
167+
@Beta
168+
@Nonnull
169+
static List<OpenAiToolMessage> execute(
170+
@Nonnull final List<OpenAiTool> tools, @Nonnull final OpenAiAssistantMessage msg) {
171+
final var toolResults = executeInternal(tools, msg);
172+
final var result = new ArrayList<OpenAiToolMessage>();
173+
for (final var entry : toolResults.entrySet()) {
174+
final var functionCall = entry.getKey().getId();
175+
final var serializedValue = serializeObject(entry.getValue());
176+
result.add(OpenAiMessage.tool(serializedValue, functionCall));
177+
}
178+
return result;
179+
}
180+
181+
/**
182+
* Executes the given tool calls with the provided tools and returns the results as a list of
183+
* {@link OpenAiToolMessage} containing execution results encoded as JSON string.
184+
*
185+
* @param tools the list of tools to execute
186+
* @param msg the assistant message containing a list of tool calls with arguments
187+
* @return a map that contains the function calls and their respective tool results.
188+
*/
189+
@Nonnull
190+
private static Map<OpenAiFunctionCall, Object> executeInternal(
191+
@Nonnull final List<OpenAiTool> tools, @Nonnull final OpenAiAssistantMessage msg) {
192+
final var result = new LinkedHashMap<OpenAiFunctionCall, Object>();
193+
final var toolMap = tools.stream().collect(Collectors.toMap(OpenAiTool::getName, identity()));
194+
for (final OpenAiToolCall toolCall : msg.toolCalls()) {
195+
if (toolCall instanceof OpenAiFunctionCall functionCall) {
196+
final var tool = toolMap.get(functionCall.getName());
197+
if (tool == null) {
198+
log.warn("Tool not found for function call: {}", functionCall.getName());
199+
continue;
200+
}
201+
final var toolResult = executeFunction(tool, functionCall);
202+
result.put(functionCall, toolResult);
203+
}
204+
}
205+
return result;
206+
}
207+
208+
@Nonnull
209+
private static Object executeFunction(
210+
@Nonnull final OpenAiTool tool, @Nonnull final OpenAiFunctionCall toolCall) {
211+
final Function<String, Object> executor = tool.getFunctionExecutor();
212+
final String arguments = toolCall.getArguments();
213+
return executor.apply(arguments);
214+
}
215+
216+
@Nonnull
217+
private static String serializeObject(@Nonnull final Object obj) throws IllegalArgumentException {
218+
try {
219+
return JACKSON.writeValueAsString(obj);
220+
} catch (JsonProcessingException e) {
221+
throw new IllegalArgumentException("Failed to serialize object to JSON", e);
222+
}
223+
}
224+
}

0 commit comments

Comments
 (0)