Skip to content

Commit 9605b66

Browse files
n-o-u-r-h-a-nJonas-IsrCharlesDuboisSAPnewtorkbot-sdk-js
authored
feat: SpringAI integration in OpenAI (#538)
* first draft * Align with docs * Codestyle * feat: [OpenAI] Spring AI integration * test * test * Fixing errors in OpenAiChatOptions according to "Upgrade to Spring AI 1.0.0 (GA Version) (#503)" * Fixing errors in OpenAiChatOptions according to "Upgrade to Spring AI 1.0.0 (GA Version) (#503)" * Fixing SpringAiAgenticWorkflowService according to "Upgrade to Spring AI 1.0.0 (GA Version) (#503)". * Implementation of completion and streamChatCompletion in SpringAiOpenAiService + their corresponding passed tests in SpringAiOpenAiTest class. Regarding the OpenAiChatModel class it was just formatting, nothing changed. * Removing a comment * Removing a comment * Chat Memory test working. * Formatting for SpringAiOpenAiService. * Removing unneccessary imports in SpringAiOpenAiService. * Updating the toOpenAiRequest in OpenAiChatModel.java. * Implementing the new approach --> 4 methods in OpenAiChatCompletionRequest.java return only this now and other constructors other than main are removed for now. * Editing the approach. * Fix compilation and format and annotations and javadoc * Remove unrelated code * implementation hint * Formatting * Updating OpenAiChatOptions.java with our Config Object. * Formatting * Passing our Config Object as an input parameter for OpenAiChatOptions() * Fixing NullPointerException in toOpenAiRequest method for ToolCallng Test to pass. * Adding topK for the Config Class ?? * Formatting * Update foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/spring/OpenAiChatModel.java Co-authored-by: Alexander Dümont <[email protected]> * Failing Test of testToolCallingWithoutExecution() in SpringAiOpenAiTest.java * Resolving Reviewed Issues. * Resolving Reviewed Issues. * --> still having testToolCallingWithoutExecution() in SpringAiOpenAiTest.java failing. --> still fix of null of message.getText() in toAssistantMessage() method in OpenAiChatModel.java pending. * format * format * Removing wild cards imports * Sucessful build of OpenAi * Sucessful build of Spring Boot app. * Formatting * Removing this test for now. * Fix nullcheck * Fix unit test * chore: Reduce constructor visibility in OpenAI / SpringAI PR (#531) * Reduce constructors * Update thresholds * Update javadoc and factory name * Replacing config.toolsExecutable with getter-usage + adding tolerate to withStop() method. * 2 * Assistant message wiht tools calls * unit test * Creating OpenAiChatModelTest.java and updating the controller and index page. * Formatting * formatting * Finishing the tests * Added more options and metadata * Formatting * Fixing Format/Style (Minor). * Fixing Format/Style (Minor). * Updating the Release notes according to integrating SpringAI with our OpenAi Client. * Update docs/release_notes.md Co-authored-by: Charles Dubois <[email protected]> * Handling JsonProcessingException * Handling JsonProcessingException * Handling JsonProcessingException * Formatting * protected extractOptions --------- Co-authored-by: Jonas Israel <[email protected]> Co-authored-by: Jonas-Isr <[email protected]> Co-authored-by: I538344 <[email protected]> Co-authored-by: Alexander Dümont <[email protected]> Co-authored-by: SAP Cloud SDK Bot <[email protected]> Co-authored-by: Alexander Dümont <[email protected]> Co-authored-by: Roshin Rajan Panackal <[email protected]> Co-authored-by: Charles Dubois <[email protected]>
1 parent 275a736 commit 9605b66

File tree

19 files changed

+1240
-14
lines changed

19 files changed

+1240
-14
lines changed

docs/release_notes.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212

1313
### ✨ New Functionality
1414

15-
- Extend `OpenAiClientException` and `OrchestrationClientException` to retrieve error diagnostics information received from remote service.
15+
- Extend `OpenAiClientException` and `OrchestrationClientException` to retrieve error diagnostics information received
16+
from remote service.
1617
New available accessors for troubleshooting: `getErrorResponse()`, `getHttpResponse()` and, `getHttpRequest()`.
1718
Please note: depending on the error response, these methods may return `null` if the information is not available.
1819
- [OpenAI] Added new models for `OpenAiModel`: `GPT_5`, `GPT_5_MINI` and `GPT_5_NANO`.
@@ -22,6 +23,8 @@
2223
`OrchestrationAiModel.GEMINI_1_5_FLASH`
2324
- Replacement are `GEMINI_2_5_PRO` and `GEMINI_2_5_FLASH`.
2425
- [Orchestration] Deprecated `OrchestrationAiModel.IBM_GRANITE_13B_CHAT` with no replacement.
26+
- [OpenAI] [Introduced SpringAI integration with our OpenAI client.](https://sap.github.io/ai-sdk/docs/java/spring-ai/openai)
27+
- Added `OpenAiChatModel`
2528

2629
### 📈 Improvements
2730

foundation-models/openai/pom.xml

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,12 @@
3838
</scm>
3939
<properties>
4040
<project.rootdir>${project.basedir}/../../</project.rootdir>
41-
<coverage.complexity>72%</coverage.complexity>
42-
<coverage.line>80%</coverage.line>
43-
<coverage.instruction>76%</coverage.instruction>
44-
<coverage.branch>70%</coverage.branch>
45-
<coverage.method>83%</coverage.method>
46-
<coverage.class>84%</coverage.class>
41+
<coverage.complexity>81%</coverage.complexity>
42+
<coverage.line>91%</coverage.line>
43+
<coverage.instruction>88%</coverage.instruction>
44+
<coverage.branch>79%</coverage.branch>
45+
<coverage.method>90%</coverage.method>
46+
<coverage.class>92%</coverage.class>
4747
</properties>
4848
<dependencies>
4949
<dependency>
@@ -112,6 +112,11 @@
112112
<artifactId>spring-ai-model</artifactId>
113113
<optional>true</optional>
114114
</dependency>
115+
<dependency>
116+
<groupId>io.projectreactor</groupId>
117+
<artifactId>reactor-core</artifactId>
118+
<optional>true</optional>
119+
</dependency>
115120
<!-- scope "provided" -->
116121
<dependency>
117122
<groupId>org.projectlombok</groupId>
@@ -149,6 +154,12 @@
149154
<artifactId>javaparser-core</artifactId>
150155
<scope>test</scope>
151156
</dependency>
157+
<dependency>
158+
<groupId>org.springframework.ai</groupId>
159+
<artifactId>spring-ai-client-chat</artifactId>
160+
<scope>test</scope>
161+
<optional>true</optional>
162+
</dependency>
152163
</dependencies>
153164

154165
<profiles>

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

Lines changed: 13 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 com.google.common.annotations.Beta;
4+
import java.util.ArrayList;
45
import java.util.List;
56
import javax.annotation.Nonnull;
67

@@ -46,6 +47,18 @@ static OpenAiAssistantMessage assistant(@Nonnull final String message) {
4647
return new OpenAiAssistantMessage(message);
4748
}
4849

50+
/**
51+
* A convenience method to create an assistant message.
52+
*
53+
* @param toolCalls tool calls to associate with the message.
54+
* @return the assistant message.
55+
*/
56+
@Nonnull
57+
static OpenAiAssistantMessage assistant(@Nonnull final List<OpenAiToolCall> toolCalls) {
58+
return new OpenAiAssistantMessage(
59+
new OpenAiMessageContent(List.of()), new ArrayList<>(toolCalls));
60+
}
61+
4962
/**
5063
* A convenience method to create a system message.
5164
*
Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,27 @@
11
package com.sap.ai.sdk.foundationmodels.openai;
22

33
import com.google.common.annotations.Beta;
4+
import javax.annotation.Nonnull;
45

56
/**
67
* Represents a tool called by an OpenAI model.
78
*
89
* @since 1.6.0
910
*/
1011
@Beta
11-
public sealed interface OpenAiToolCall permits OpenAiFunctionCall {}
12+
public sealed interface OpenAiToolCall permits OpenAiFunctionCall {
13+
/**
14+
* Creates a new instance of {@link OpenAiToolCall}.
15+
*
16+
* @param id The unique identifier for the tool call.
17+
* @param name The name of the tool to be called.
18+
* @param arguments The arguments for the tool call, encoded as a JSON string.
19+
* @return A new instance of {@link OpenAiToolCall}.
20+
* @since 1.10.0
21+
*/
22+
@Nonnull
23+
static OpenAiToolCall function(
24+
@Nonnull final String id, @Nonnull final String name, @Nonnull final String arguments) {
25+
return new OpenAiFunctionCall(id, name, arguments);
26+
}
27+
}
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
package com.sap.ai.sdk.foundationmodels.openai.spring;
2+
3+
import static org.springframework.ai.model.tool.ToolCallingChatOptions.isInternalToolExecutionEnabled;
4+
5+
import com.fasterxml.jackson.core.JsonProcessingException;
6+
import com.fasterxml.jackson.core.type.TypeReference;
7+
import com.fasterxml.jackson.databind.ObjectMapper;
8+
import com.sap.ai.sdk.foundationmodels.openai.OpenAiChatCompletionDelta;
9+
import com.sap.ai.sdk.foundationmodels.openai.OpenAiChatCompletionRequest;
10+
import com.sap.ai.sdk.foundationmodels.openai.OpenAiChatCompletionResponse;
11+
import com.sap.ai.sdk.foundationmodels.openai.OpenAiClient;
12+
import com.sap.ai.sdk.foundationmodels.openai.OpenAiMessage;
13+
import com.sap.ai.sdk.foundationmodels.openai.OpenAiToolCall;
14+
import com.sap.ai.sdk.foundationmodels.openai.generated.model.ChatCompletionMessageToolCall;
15+
import com.sap.ai.sdk.foundationmodels.openai.generated.model.ChatCompletionTool;
16+
import com.sap.ai.sdk.foundationmodels.openai.generated.model.CreateChatCompletionResponseChoicesInner;
17+
import com.sap.ai.sdk.foundationmodels.openai.generated.model.FunctionObject;
18+
import io.vavr.control.Option;
19+
import java.math.BigDecimal;
20+
import java.util.ArrayList;
21+
import java.util.List;
22+
import java.util.Map;
23+
import java.util.function.Function;
24+
import javax.annotation.Nonnull;
25+
import lombok.RequiredArgsConstructor;
26+
import lombok.extern.slf4j.Slf4j;
27+
import lombok.val;
28+
import org.springframework.ai.chat.messages.AssistantMessage;
29+
import org.springframework.ai.chat.messages.AssistantMessage.ToolCall;
30+
import org.springframework.ai.chat.messages.Message;
31+
import org.springframework.ai.chat.messages.ToolResponseMessage;
32+
import org.springframework.ai.chat.metadata.ChatGenerationMetadata;
33+
import org.springframework.ai.chat.model.ChatModel;
34+
import org.springframework.ai.chat.model.ChatResponse;
35+
import org.springframework.ai.chat.model.Generation;
36+
import org.springframework.ai.chat.prompt.ChatOptions;
37+
import org.springframework.ai.chat.prompt.Prompt;
38+
import org.springframework.ai.model.tool.DefaultToolCallingManager;
39+
import org.springframework.ai.model.tool.ToolCallingChatOptions;
40+
import reactor.core.publisher.Flux;
41+
42+
/**
43+
* OpenAI Chat Model implementation that interacts with the OpenAI API to generate chat completions.
44+
*/
45+
@Slf4j
46+
@RequiredArgsConstructor
47+
public class OpenAiChatModel implements ChatModel {
48+
49+
private final OpenAiClient client;
50+
51+
@Nonnull
52+
private final DefaultToolCallingManager toolCallingManager =
53+
DefaultToolCallingManager.builder().build();
54+
55+
@Override
56+
@Nonnull
57+
public ChatResponse call(@Nonnull final Prompt prompt) {
58+
val options = prompt.getOptions();
59+
var request = new OpenAiChatCompletionRequest(extractMessages(prompt));
60+
61+
if (options != null) {
62+
request = extractOptions(request, options);
63+
}
64+
if ((options instanceof ToolCallingChatOptions toolOptions)) {
65+
request = request.withTools(extractTools(toolOptions));
66+
}
67+
68+
val result = client.chatCompletion(request);
69+
val response = new ChatResponse(toGenerations(result));
70+
71+
if (options != null && isInternalToolExecutionEnabled(options) && response.hasToolCalls()) {
72+
val toolExecutionResult = toolCallingManager.executeToolCalls(prompt, response);
73+
// Send the tool execution result back to the model.
74+
return call(new Prompt(toolExecutionResult.conversationHistory(), options));
75+
}
76+
return response;
77+
}
78+
79+
@Override
80+
@Nonnull
81+
public Flux<ChatResponse> stream(@Nonnull final Prompt prompt) {
82+
val options = prompt.getOptions();
83+
var request = new OpenAiChatCompletionRequest(extractMessages(prompt));
84+
85+
if (options != null) {
86+
request = extractOptions(request, options);
87+
}
88+
if ((options instanceof ToolCallingChatOptions toolOptions)) {
89+
request = request.withTools(extractTools(toolOptions));
90+
}
91+
92+
val stream = client.streamChatCompletionDeltas(request);
93+
final Flux<OpenAiChatCompletionDelta> flux =
94+
Flux.generate(
95+
stream::iterator,
96+
(iterator, sink) -> {
97+
if (iterator.hasNext()) {
98+
sink.next(iterator.next());
99+
} else {
100+
sink.complete();
101+
}
102+
return iterator;
103+
});
104+
return flux.map(
105+
delta -> {
106+
val assistantMessage = new AssistantMessage(delta.getDeltaContent(), Map.of());
107+
val metadata =
108+
ChatGenerationMetadata.builder().finishReason(delta.getFinishReason()).build();
109+
return new ChatResponse(List.of(new Generation(assistantMessage, metadata)));
110+
});
111+
}
112+
113+
private static List<OpenAiMessage> extractMessages(final Prompt prompt) {
114+
final List<OpenAiMessage> result = new ArrayList<>();
115+
for (final Message message : prompt.getInstructions()) {
116+
switch (message.getMessageType()) {
117+
case USER -> Option.of(message.getText()).peek(t -> result.add(OpenAiMessage.user(t)));
118+
case SYSTEM -> Option.of(message.getText()).peek(t -> result.add(OpenAiMessage.system(t)));
119+
case ASSISTANT -> addAssistantMessage(result, (AssistantMessage) message);
120+
case TOOL -> addToolMessages(result, (ToolResponseMessage) message);
121+
}
122+
}
123+
return result;
124+
}
125+
126+
private static void addAssistantMessage(
127+
final List<OpenAiMessage> result, final AssistantMessage message) {
128+
if (message.getText() != null) {
129+
result.add(OpenAiMessage.assistant(message.getText()));
130+
return;
131+
}
132+
final Function<ToolCall, OpenAiToolCall> callTranslate =
133+
toolCall -> OpenAiToolCall.function(toolCall.id(), toolCall.name(), toolCall.arguments());
134+
val calls = message.getToolCalls().stream().map(callTranslate).toList();
135+
result.add(OpenAiMessage.assistant(calls));
136+
}
137+
138+
private static void addToolMessages(
139+
final List<OpenAiMessage> result, final ToolResponseMessage message) {
140+
for (final ToolResponseMessage.ToolResponse response : message.getResponses()) {
141+
result.add(OpenAiMessage.tool(response.responseData(), response.id()));
142+
}
143+
}
144+
145+
@Nonnull
146+
private static List<Generation> toGenerations(
147+
@Nonnull final OpenAiChatCompletionResponse result) {
148+
return result.getOriginalResponse().getChoices().stream()
149+
.map(OpenAiChatModel::toGeneration)
150+
.toList();
151+
}
152+
153+
@Nonnull
154+
private static Generation toGeneration(
155+
@Nonnull final CreateChatCompletionResponseChoicesInner choice) {
156+
val metadata =
157+
ChatGenerationMetadata.builder().finishReason(choice.getFinishReason().getValue());
158+
metadata.metadata("index", choice.getIndex());
159+
if (choice.getLogprobs() != null && !choice.getLogprobs().getContent().isEmpty()) {
160+
metadata.metadata("logprobs", choice.getLogprobs().getContent());
161+
}
162+
val message = choice.getMessage();
163+
val calls = new ArrayList<ToolCall>();
164+
if (message.getToolCalls() != null) {
165+
for (final ChatCompletionMessageToolCall c : message.getToolCalls()) {
166+
val fnc = c.getFunction();
167+
calls.add(
168+
new ToolCall(c.getId(), c.getType().getValue(), fnc.getName(), fnc.getArguments()));
169+
}
170+
}
171+
172+
val assistantMessage = new AssistantMessage(message.getContent(), Map.of(), calls);
173+
return new Generation(assistantMessage, metadata.build());
174+
}
175+
176+
/**
177+
* Adds options to the request.
178+
*
179+
* @param request the request to modify
180+
* @param options the options to extract
181+
* @return the modified request with options applied
182+
*/
183+
@Nonnull
184+
protected static OpenAiChatCompletionRequest extractOptions(
185+
@Nonnull OpenAiChatCompletionRequest request, @Nonnull final ChatOptions options) {
186+
request = request.withStop(options.getStopSequences()).withMaxTokens(options.getMaxTokens());
187+
if (options.getTemperature() != null) {
188+
request = request.withTemperature(BigDecimal.valueOf(options.getTemperature()));
189+
}
190+
if (options.getTopP() != null) {
191+
request = request.withTopP(BigDecimal.valueOf(options.getTopP()));
192+
}
193+
if (options.getPresencePenalty() != null) {
194+
request = request.withPresencePenalty(BigDecimal.valueOf(options.getPresencePenalty()));
195+
}
196+
if (options.getFrequencyPenalty() != null) {
197+
request = request.withFrequencyPenalty(BigDecimal.valueOf(options.getFrequencyPenalty()));
198+
}
199+
return request;
200+
}
201+
202+
private static List<ChatCompletionTool> extractTools(final ToolCallingChatOptions options) {
203+
val tools = new ArrayList<ChatCompletionTool>();
204+
for (val toolCallback : options.getToolCallbacks()) {
205+
val toolDefinition = toolCallback.getToolDefinition();
206+
try {
207+
final Map<String, Object> params =
208+
new ObjectMapper().readValue(toolDefinition.inputSchema(), new TypeReference<>() {});
209+
val toolType = ChatCompletionTool.TypeEnum.FUNCTION;
210+
val toolFunction =
211+
new FunctionObject()
212+
.name(toolDefinition.name())
213+
.description(toolDefinition.description())
214+
.parameters(params);
215+
val tool = new ChatCompletionTool().type(toolType).function(toolFunction);
216+
tools.add(tool);
217+
} catch (JsonProcessingException e) {
218+
log.warn("Failed to add tool to the chat request: {}", e.getMessage());
219+
}
220+
}
221+
return tools;
222+
}
223+
}

0 commit comments

Comments
 (0)