Skip to content

Commit f7ebcad

Browse files
rpanackalbot-sdk-jsCharlesDuboisSAP
authored
feat: [OpenAI] Full Tool call Convenience (#396)
* Tool call full e2e unverified * CI * Reuse tool * Remove unnecessary line inserts * Create schema with jackson * First version - Mostly tested - API design complete - Function Call as message content item - Non nullability of `Message.content()` * Formatting * Testing and javadocs * improve test clarity * Remove strict tool invocation config * Fix maven dependency scope issue * Fix maven dependency scope issue + 1 * Refactor. toolCalls get dedicated field in assistant message - OpenAiAssistantMessage().content() may contain empty list * minor fixes - final keyword - remove redundant assertion - variable naming * minor fixes - final keyword - variable naming * Test message list in request externally unmodifiable * Test message list in request externally unmodifiable * minor javadoc update * Remove sample app changes * @beta annotation * Update foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionResponse.java Co-authored-by: Charles Dubois <[email protected]> * Apply suggestions from code review javadoc and naming Co-authored-by: Charles Dubois <[email protected]> * update getMessage method impl readability * Move test to generated client test class * Extend tests * Reduce test * Refactor test * Formatting * import statement --------- Co-authored-by: Roshin Rajan Panackal <[email protected]> Co-authored-by: SAP Cloud SDK Bot <[email protected]> Co-authored-by: Charles Dubois <[email protected]>
1 parent e380c8d commit f7ebcad

File tree

11 files changed

+233
-36
lines changed

11 files changed

+233
-36
lines changed

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

Lines changed: 53 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,12 @@
33
import static lombok.AccessLevel.PACKAGE;
44

55
import com.google.common.annotations.Beta;
6+
import com.sap.ai.sdk.foundationmodels.openai.generated.model.ChatCompletionMessageToolCall;
7+
import com.sap.ai.sdk.foundationmodels.openai.generated.model.ChatCompletionMessageToolCallFunction;
68
import com.sap.ai.sdk.foundationmodels.openai.generated.model.ChatCompletionRequestAssistantMessage;
79
import com.sap.ai.sdk.foundationmodels.openai.generated.model.ChatCompletionRequestAssistantMessageContent;
10+
import com.sap.ai.sdk.foundationmodels.openai.generated.model.ToolCallType;
11+
import java.util.Collections;
812
import java.util.List;
913
import javax.annotation.Nonnull;
1014
import lombok.AllArgsConstructor;
@@ -15,29 +19,48 @@
1519
/**
1620
* Represents a chat message as 'assistant' to OpenAI service.
1721
*
22+
* <p>When {@link OpenAiAssistantMessage} is received from {@link OpenAiChatCompletionResponse}, it
23+
* may contain tool calls that need to be executed. The tool calls are represented as {@link
24+
* OpenAiToolCall}.
25+
*
1826
* @since 1.4.0
1927
*/
2028
@Beta
2129
@Value
2230
@Accessors(fluent = true)
2331
@AllArgsConstructor(access = PACKAGE)
24-
class OpenAiAssistantMessage implements OpenAiMessage {
32+
public class OpenAiAssistantMessage implements OpenAiMessage {
2533

2634
/** The role associated with this message. */
2735
@Nonnull String role = "assistant";
2836

29-
/** The content of the message. */
37+
/**
38+
* The content of the message.
39+
*
40+
* <p>May contain an empty list of {@link OpenAiContentItem} when tool calls are present.
41+
*/
3042
@Getter(onMethod_ = @Beta)
3143
@Nonnull
3244
OpenAiMessageContent content;
3345

3446
/**
35-
* Creates a new assistant message with the given single message.
47+
* The tool calls associated with this message if present.
48+
*
49+
* @since 1.6.0
50+
*/
51+
@Getter(onMethod_ = @Beta)
52+
@Nonnull
53+
List<OpenAiToolCall> toolCalls;
54+
55+
/**
56+
* Creates a new assistant message with the given single message as text content.
3657
*
3758
* @param singleMessage the message.
3859
*/
3960
OpenAiAssistantMessage(@Nonnull final String singleMessage) {
40-
this(new OpenAiMessageContent(List.of(new OpenAiTextItem(singleMessage))));
61+
this(
62+
new OpenAiMessageContent(List.of(new OpenAiTextItem(singleMessage))),
63+
Collections.emptyList());
4164
}
4265

4366
/**
@@ -47,9 +70,31 @@ class OpenAiAssistantMessage implements OpenAiMessage {
4770
*/
4871
@Nonnull
4972
ChatCompletionRequestAssistantMessage createChatCompletionRequestMessage() {
50-
final var textItem = (OpenAiTextItem) this.content().items().get(0);
51-
return new ChatCompletionRequestAssistantMessage()
52-
.role(ChatCompletionRequestAssistantMessage.RoleEnum.fromValue(role()))
53-
.content(ChatCompletionRequestAssistantMessageContent.create(textItem.text()));
73+
final var message =
74+
new ChatCompletionRequestAssistantMessage()
75+
.role(ChatCompletionRequestAssistantMessage.RoleEnum.fromValue(role()));
76+
77+
final var items = content().items();
78+
if (!items.isEmpty() && items.get(0) instanceof OpenAiTextItem textItem) {
79+
message.content(ChatCompletionRequestAssistantMessageContent.create(textItem.text()));
80+
}
81+
82+
for (final var item : toolCalls()) {
83+
if (item instanceof OpenAiFunctionCall functionItem) {
84+
final var functionCall =
85+
new ChatCompletionMessageToolCallFunction()
86+
.name(functionItem.getName())
87+
.arguments(functionItem.getArguments());
88+
89+
final var toolCall =
90+
new ChatCompletionMessageToolCall()
91+
.type(ToolCallType.FUNCTION)
92+
.id(functionItem.getId())
93+
.function(functionCall);
94+
95+
message.addToolCallsItem(toolCall);
96+
}
97+
}
98+
return message;
5499
}
55100
}

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

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,8 +149,19 @@ public OpenAiChatCompletionRequest(@Nonnull final String message) {
149149
@Tolerate
150150
public OpenAiChatCompletionRequest(
151151
@Nonnull final OpenAiMessage message, @Nonnull final OpenAiMessage... messages) {
152+
this(Lists.asList(message, messages));
153+
}
154+
155+
/**
156+
* Creates an OpenAiChatCompletionPrompt with a list of messages.
157+
*
158+
* @param messages the list of messages to be added to the prompt
159+
* @since 1.6.0
160+
*/
161+
@Tolerate
162+
public OpenAiChatCompletionRequest(@Nonnull final List<OpenAiMessage> messages) {
152163
this(
153-
Lists.asList(message, messages),
164+
List.copyOf(messages),
154165
null,
155166
null,
156167
null,

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

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import com.sap.ai.sdk.foundationmodels.openai.generated.model.CompletionUsage;
99
import com.sap.ai.sdk.foundationmodels.openai.generated.model.CreateChatCompletionResponse;
1010
import com.sap.ai.sdk.foundationmodels.openai.generated.model.CreateChatCompletionResponseChoicesInner;
11+
import java.util.List;
1112
import java.util.Objects;
1213
import javax.annotation.Nonnull;
1314
import lombok.RequiredArgsConstructor;
@@ -50,6 +51,9 @@ public CreateChatCompletionResponseChoicesInner getChoice() {
5051
/**
5152
* Gets the content of the first choice.
5253
*
54+
* <p>The content may be empty {@code ""} if the assistant did not return any content i.e. when
55+
* tool calls are present.
56+
*
5357
* @return the content of the first choice
5458
* @throws OpenAiClientException if the content is filtered by the content filter
5559
*/
@@ -61,4 +65,35 @@ public String getContent() {
6165

6266
return Objects.requireNonNullElse(getChoice().getMessage().getContent(), "");
6367
}
68+
69+
/**
70+
* Gets the {@code OpenAiAssistantMessage} for the first choice.
71+
*
72+
* @return the assistant message
73+
* @throws OpenAiClientException if the content is filtered by the content filter
74+
* @since 1.6.0
75+
*/
76+
@Nonnull
77+
public OpenAiAssistantMessage getMessage() {
78+
final var toolCalls = getChoice().getMessage().getToolCalls();
79+
80+
if (toolCalls == null) {
81+
return OpenAiMessage.assistant(getContent());
82+
}
83+
84+
final List<OpenAiContentItem> contentItems =
85+
getContent().isEmpty() ? List.of() : List.of(new OpenAiTextItem(getContent()));
86+
87+
final var openAiToolCalls =
88+
toolCalls.stream()
89+
.<OpenAiToolCall>map(
90+
toolCall ->
91+
new OpenAiFunctionCall(
92+
toolCall.getId(),
93+
toolCall.getFunction().getName(),
94+
toolCall.getFunction().getArguments()))
95+
.toList();
96+
97+
return new OpenAiAssistantMessage(new OpenAiMessageContent(contentItems), openAiToolCalls);
98+
}
6499
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package com.sap.ai.sdk.foundationmodels.openai;
2+
3+
import com.google.common.annotations.Beta;
4+
import javax.annotation.Nonnull;
5+
import lombok.AllArgsConstructor;
6+
import lombok.Value;
7+
8+
/**
9+
* Represents a function type tool called by an OpenAI model.
10+
*
11+
* @since 1.6.0
12+
*/
13+
@Beta
14+
@Value
15+
@AllArgsConstructor(access = lombok.AccessLevel.PACKAGE)
16+
public class OpenAiFunctionCall implements OpenAiToolCall {
17+
/** The unique identifier for the function call. */
18+
@Nonnull String id;
19+
20+
/** The name of the function to be called. */
21+
@Nonnull String name;
22+
23+
/** The arguments for the function call, encoded as a JSON string. */
24+
@Nonnull String arguments;
25+
}

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,8 @@ static OpenAiSystemMessage system(@Nonnull final String message) {
6060
/**
6161
* A convenience method to create a tool message.
6262
*
63-
* @param message the message content.
64-
* @param toolCallId identifier of the tool call this message is responding to.
63+
* @param message response of the executed tool call.
64+
* @param toolCallId identifier of the tool call that the assistant expected.
6565
* @return the tool message.
6666
*/
6767
@Nonnull
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package com.sap.ai.sdk.foundationmodels.openai;
2+
3+
import com.google.common.annotations.Beta;
4+
5+
/**
6+
* Represents a tool called by an OpenAI model.
7+
*
8+
* @since 1.6.0
9+
*/
10+
@Beta
11+
public sealed interface OpenAiToolCall permits OpenAiFunctionCall {}

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,17 +25,17 @@ public class OpenAiToolMessage implements OpenAiMessage {
2525
/** The role associated with this message. */
2626
@Nonnull String role = "tool";
2727

28-
/** The content of the message. */
28+
/** Response of the executed tool call. */
2929
@Nonnull OpenAiMessageContent content;
3030

3131
/** The tool call id associated with this message. */
3232
@Nonnull private final String toolCallId;
3333

3434
/**
35-
* Creates a new tool message from a string and tool call id.
35+
* Creates a new tool message from a tool execution response and tool call id.
3636
*
37-
* @param message the first message.
38-
* @param toolCallId identifier of the tool call this message is responding to.
37+
* @param message response of the executed tool call.
38+
* @param toolCallId identifier of the tool call that the assistant expected.
3939
*/
4040
OpenAiToolMessage(@Nonnull final String message, @Nonnull final String toolCallId) {
4141
this(new OpenAiMessageContent(List.of(new OpenAiTextItem(message))), toolCallId);

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ static ChatCompletionRequestMessage createChatCompletionRequestMessage(
3434
return assistantMessage.createChatCompletionRequestMessage();
3535
} else if (message instanceof OpenAiSystemMessage systemMessage) {
3636
return systemMessage.createChatCompletionRequestMessage();
37+
} else if (message instanceof OpenAiToolMessage toolMessage) {
38+
return toolMessage.createChatCompletionRequestMessage();
3739
} else {
3840
throw new IllegalArgumentException("Unknown message type: " + message.getClass());
3941
}

foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAIMessageTest.java

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.sap.ai.sdk.foundationmodels.openai;
22

33
import static org.assertj.core.api.Assertions.assertThat;
4+
import static org.assertj.core.api.Assertions.assertThatNoException;
45

56
import com.sap.ai.sdk.foundationmodels.openai.generated.model.ChatCompletionRequestAssistantMessage;
67
import com.sap.ai.sdk.foundationmodels.openai.generated.model.ChatCompletionRequestAssistantMessageContent;
@@ -13,6 +14,7 @@
1314
import com.sap.ai.sdk.foundationmodels.openai.generated.model.ChatCompletionRequestToolMessageContent;
1415
import com.sap.ai.sdk.foundationmodels.openai.generated.model.ChatCompletionRequestUserMessage;
1516
import com.sap.ai.sdk.foundationmodels.openai.generated.model.ChatCompletionRequestUserMessageContent;
17+
import com.sap.ai.sdk.foundationmodels.openai.generated.model.ToolCallType;
1618
import java.net.URI;
1719
import java.util.List;
1820
import java.util.stream.Stream;
@@ -194,6 +196,22 @@ void assistantMessageToDto() {
194196
((ChatCompletionRequestAssistantMessageContent.InnerString) requestMessage.getContent())
195197
.value())
196198
.isEqualTo(validText);
199+
200+
var messageWithFunctionCall =
201+
new OpenAiAssistantMessage(
202+
new OpenAiMessageContent(List.of()),
203+
List.of(new OpenAiFunctionCall("id", "name", "arguments")));
204+
var requestMessageWithFunctionCall =
205+
messageWithFunctionCall.createChatCompletionRequestMessage();
206+
207+
assertThat(requestMessageWithFunctionCall.getToolCalls()).hasSize(1);
208+
assertThat(requestMessageWithFunctionCall.getToolCalls().get(0).getType())
209+
.isEqualTo(ToolCallType.FUNCTION);
210+
assertThat(requestMessageWithFunctionCall.getToolCalls().get(0).getId()).isEqualTo("id");
211+
assertThat(requestMessageWithFunctionCall.getToolCalls().get(0).getFunction().getName())
212+
.isEqualTo("name");
213+
assertThat(requestMessageWithFunctionCall.getToolCalls().get(0).getFunction().getArguments())
214+
.isEqualTo("arguments");
197215
}
198216

199217
@Test
@@ -220,4 +238,11 @@ void throwOnSystemMessageWithImage() {
220238
.hasMessageContaining(
221239
"Unknown content type for class com.sap.ai.sdk.foundationmodels.openai.OpenAiImageItem messages.");
222240
}
241+
242+
@ParameterizedTest
243+
@MethodSource("provideValidTextMessageByRole")
244+
void testCreateChatCompletionRequestMessage(OpenAiMessage message) {
245+
assertThatNoException()
246+
.isThrownBy(() -> OpenAiUtils.createChatCompletionRequestMessage(message));
247+
}
223248
}

foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/OpenAiChatCompletionRequestTest.java

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import com.sap.ai.sdk.foundationmodels.openai.generated.model.ChatCompletionToolChoiceOption;
88
import com.sap.ai.sdk.foundationmodels.openai.generated.model.CreateChatCompletionRequestAllOfStop;
99
import java.math.BigDecimal;
10+
import java.util.ArrayList;
1011
import java.util.List;
1112
import org.junit.jupiter.api.Test;
1213

@@ -94,4 +95,23 @@ void withToolChoiceFunction() {
9495
assertThat(choice.getType().getValue()).isEqualTo("function");
9596
assertThat(choice.getFunction().getName()).isEqualTo("functionName");
9697
}
98+
99+
@Test
100+
void messageListExternallyUnmodifiable() {
101+
var originalList = new ArrayList<OpenAiMessage>();
102+
OpenAiUserMessage user = OpenAiMessage.user("Initial message");
103+
originalList.add(user);
104+
105+
var request = new OpenAiChatCompletionRequest(originalList);
106+
107+
var generatedRequest = request.createCreateChatCompletionRequest();
108+
assertThat(generatedRequest.getMessages()).hasSize(1);
109+
110+
originalList.add(OpenAiMessage.user("Another message"));
111+
112+
var generatedRequestAfter = request.createCreateChatCompletionRequest();
113+
assertThat(generatedRequestAfter.getMessages())
114+
.as("Modifying the original list should not affect the messages in the request object.")
115+
.hasSize(1);
116+
}
97117
}

0 commit comments

Comments
 (0)