diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml index 0e7e61203..7bffc6829 100644 --- a/.github/dependabot.yaml +++ b/.github/dependabot.yaml @@ -13,17 +13,6 @@ updates: prefix: 'chore: [DevOps] ' cooldown: default-days: 4 - ignore: - - dependency-name: "com.fasterxml.jackson.*:*" - versions: [ ">=3.0.0" ] - - dependency-name: "tools.jackson.*:*" - versions: [ ">=3.0.0" ] - - dependency-name: "com.github.victools:jsonschema-generator" - versions: [ ">=5.0.0" ] - - dependency-name: "com.github.victools:jsonschema-module-jackson" - versions: [ ">=5.0.0" ] - - dependency-name: "org.springframework.ai:spring-ai-bom" - versions: [ ">=2.0.0" ] groups: production-minor-patch: dependency-type: "production" diff --git a/.github/workflows/e2e-test.yaml b/.github/workflows/e2e-test.yaml index 3213825d4..f7d8a615d 100644 --- a/.github/workflows/e2e-test.yaml +++ b/.github/workflows/e2e-test.yaml @@ -105,7 +105,7 @@ jobs: run: wget -qO- -S localhost:8080 - name: "Slack Notification" - if: failure() + if: github.ref_name == 'main' && failure() uses: slackapi/slack-github-action@v4.0.0 with: webhook: ${{ secrets.SLACK_WEBHOOK }} diff --git a/docs/release_notes.md b/docs/release_notes.md index b60c091dc..5df2e0dd1 100644 --- a/docs/release_notes.md +++ b/docs/release_notes.md @@ -9,6 +9,7 @@ ### 🔧 Compatibility Notes - [Foundation models] SAP-RPT was updated to the newer 1.6.0 API +- [Orchestration] Spring AI support was upgraded to version `2.0.1` ### ✨ New Functionality diff --git a/foundation-models/openai/pom.xml b/foundation-models/openai/pom.xml index 18b4ea321..f80c6f41a 100644 --- a/foundation-models/openai/pom.xml +++ b/foundation-models/openai/pom.xml @@ -94,6 +94,10 @@ com.github.victools jsonschema-module-jackson + + tools.jackson.core + jackson-databind + io.vavr 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 af14b1be5..84f7f9962 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 @@ -86,7 +86,13 @@ public static Builder1 forFunction(@Nonnull final Function { final Function exec = s -> function.apply(deserializeArgument(inputClass, s)); - final var schema = GENERATOR.generateSchema(inputClass); + final var jackson3Schema = GENERATOR.generateSchema(inputClass); + final ObjectNode schema; + try { + schema = (ObjectNode) JACKSON.readTree(jackson3Schema.toString()); + } catch (JsonProcessingException e) { + throw new IllegalStateException("Failed to parse generated JSON schema", e); + } return new OpenAiTool(name, exec, schema, null, null); }; } diff --git a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/spring/OpenAiChatModel.java b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/spring/OpenAiChatModel.java index a38d99ff7..22a998ad4 100644 --- a/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/spring/OpenAiChatModel.java +++ b/foundation-models/openai/src/main/java/com/sap/ai/sdk/foundationmodels/openai/spring/OpenAiChatModel.java @@ -1,7 +1,5 @@ package com.sap.ai.sdk.foundationmodels.openai.spring; -import static org.springframework.ai.model.tool.ToolCallingChatOptions.isInternalToolExecutionEnabled; - import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; @@ -35,7 +33,7 @@ import org.springframework.ai.chat.model.Generation; import org.springframework.ai.chat.prompt.ChatOptions; import org.springframework.ai.chat.prompt.Prompt; -import org.springframework.ai.model.tool.DefaultToolCallingManager; +import org.springframework.ai.model.tool.DefaultToolCallingChatOptions; import org.springframework.ai.model.tool.ToolCallingChatOptions; import reactor.core.publisher.Flux; @@ -49,8 +47,10 @@ public class OpenAiChatModel implements ChatModel { private final OpenAiClient client; @Nonnull - private final DefaultToolCallingManager toolCallingManager = - DefaultToolCallingManager.builder().build(); + @Override + public ChatOptions getOptions() { + return DefaultToolCallingChatOptions.builder().toolCallbacks(List.of()).build(); + } @Override @Nonnull @@ -66,18 +66,7 @@ public ChatResponse call(@Nonnull final Prompt prompt) { } val result = client.chatCompletion(request); - val response = new ChatResponse(toGenerations(result)); - - if (options != null && isInternalToolExecutionEnabled(options) && response.hasToolCalls()) { - val toolCalls = - response.getResult().getOutput().getToolCalls().stream().map(ToolCall::name).toList(); - log.info("Executing {} tool call(s) - {}.", toolCalls.size(), toolCalls); - val toolExecutionResult = toolCallingManager.executeToolCalls(prompt, response); - // Send the tool execution result back to the model. - log.debug("Re-invoking model with tool execution results."); - return call(new Prompt(toolExecutionResult.conversationHistory(), options)); - } - return response; + return new ChatResponse(toGenerations(result)); } @Override @@ -129,14 +118,15 @@ private static List extractMessages(final Prompt prompt) { private static void addAssistantMessage( final List result, final AssistantMessage message) { - if (message.getText() != null) { - result.add(OpenAiMessage.assistant(message.getText())); + final var toolCalls = message.getToolCalls(); + if (toolCalls != null && !toolCalls.isEmpty()) { + final Function callTranslate = + toolCall -> OpenAiToolCall.function(toolCall.id(), toolCall.name(), toolCall.arguments()); + val calls = toolCalls.stream().map(callTranslate).toList(); + result.add(OpenAiMessage.assistant(calls)); return; } - final Function callTranslate = - toolCall -> OpenAiToolCall.function(toolCall.id(), toolCall.name(), toolCall.arguments()); - val calls = message.getToolCalls().stream().map(callTranslate).toList(); - result.add(OpenAiMessage.assistant(calls)); + Option.of(message.getText()).peek(t -> result.add(OpenAiMessage.assistant(t))); } private static void addToolMessages( diff --git a/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/spring/OpenAiChatModelTest.java b/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/spring/OpenAiChatModelTest.java index 3f92e35ee..6fb1478c7 100644 --- a/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/spring/OpenAiChatModelTest.java +++ b/foundation-models/openai/src/test/java/com/sap/ai/sdk/foundationmodels/openai/spring/OpenAiChatModelTest.java @@ -134,9 +134,10 @@ void testToolCallsWithoutExecution() throws IOException { .withHeader("Content-Type", "application/json") .withBodyFile("weatherToolResponse.json"))); - var options = new DefaultToolCallingChatOptions(); - options.setToolCallbacks(List.of(ToolCallbacks.from(new WeatherMethod()))); - options.setInternalToolExecutionEnabled(false); + var options = + DefaultToolCallingChatOptions.builder() + .toolCallbacks(ToolCallbacks.from(new WeatherMethod())) + .build(); val prompt = new Prompt("What is the weather in Potsdam and in Toulouse?", options); val result = client.call(prompt); @@ -178,10 +179,16 @@ void testToolCallsWithExecution() throws IOException { .withBodyFile("weatherToolResponse2.json") .withHeader("Content-Type", "application/json"))); - var options = new DefaultToolCallingChatOptions(); - options.setToolCallbacks(List.of(ToolCallbacks.from(new WeatherMethod()))); - val prompt = new Prompt("What is the weather in Potsdam and in Toulouse?", options); - val result = client.call(prompt); + var options = + DefaultToolCallingChatOptions.builder() + .toolCallbacks(ToolCallbacks.from(new WeatherMethod())) + .build(); + val chatClient = ChatClient.builder(client).build(); + val result = + chatClient + .prompt(new Prompt("What is the weather in Potsdam and in Toulouse?", options)) + .call() + .chatResponse(); assertThat(result.getResult().getOutput().getText()) .isEqualTo("The current temperature in Potsdam is 30°C and in Toulouse 30°C."); diff --git a/orchestration/pom.xml b/orchestration/pom.xml index e5d998154..e1fa08555 100644 --- a/orchestration/pom.xml +++ b/orchestration/pom.xml @@ -118,6 +118,10 @@ com.github.victools jsonschema-module-jackson + + tools.jackson.core + jackson-databind + com.fasterxml.jackson.dataformat jackson-dataformat-yaml diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/ResponseJsonSchema.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/ResponseJsonSchema.java index 718db8755..0ba8b8cde 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/ResponseJsonSchema.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/ResponseJsonSchema.java @@ -1,5 +1,6 @@ package com.sap.ai.sdk.orchestration; +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; @@ -7,8 +8,8 @@ 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.github.victools.jsonschema.module.jackson.JacksonSchemaModule; import java.lang.reflect.Type; import java.util.Map; import javax.annotation.Nonnull; @@ -63,7 +64,7 @@ public static ResponseJsonSchema fromMap( @Nonnull public static ResponseJsonSchema fromType(@Nonnull final Type classType) { val module = - new JacksonModule( + new JacksonSchemaModule( JacksonOption.RESPECT_JSONPROPERTY_REQUIRED, JacksonOption.RESPECT_JSONPROPERTY_ORDER); val generator = new SchemaGenerator( @@ -73,8 +74,12 @@ public static ResponseJsonSchema fromType(@Nonnull final Type classType) { .with(module) .build()); val jsonSchema = generator.generateSchema(classType); - val mapper = new ObjectMapper(); - val schemaMap = mapper.convertValue(jsonSchema, new TypeReference>() {}); + final Map schemaMap; + try { + schemaMap = new ObjectMapper().readValue(jsonSchema.toString(), new TypeReference<>() {}); + } catch (JsonProcessingException e) { + throw new IllegalStateException("Failed to parse generated JSON schema", e); + } val schemaName = ((Class) classType).getSimpleName() + "-Schema"; return new ResponseJsonSchema(schemaMap, schemaName, null, null); } diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/spring/OrchestrationChatModel.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/spring/OrchestrationChatModel.java index 44310e005..741239c2c 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/spring/OrchestrationChatModel.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/spring/OrchestrationChatModel.java @@ -6,6 +6,7 @@ import com.sap.ai.sdk.orchestration.AssistantMessage; import com.sap.ai.sdk.orchestration.OrchestrationChatCompletionDelta; import com.sap.ai.sdk.orchestration.OrchestrationClient; +import com.sap.ai.sdk.orchestration.OrchestrationModuleConfig; import com.sap.ai.sdk.orchestration.OrchestrationPrompt; import com.sap.ai.sdk.orchestration.SystemMessage; import com.sap.ai.sdk.orchestration.ToolMessage; @@ -15,8 +16,9 @@ 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.Setter; import lombok.extern.slf4j.Slf4j; import lombok.val; import org.springframework.ai.chat.messages.AssistantMessage.ToolCall; @@ -24,9 +26,8 @@ import org.springframework.ai.chat.messages.ToolResponseMessage; import org.springframework.ai.chat.model.ChatModel; import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.prompt.ChatOptions; import org.springframework.ai.chat.prompt.Prompt; -import org.springframework.ai.model.tool.DefaultToolCallingManager; -import org.springframework.ai.model.tool.ToolCallingChatOptions; import reactor.core.publisher.Flux; /** @@ -38,9 +39,7 @@ public class OrchestrationChatModel implements ChatModel { @Nonnull private final OrchestrationClient client; - @Nonnull - private final DefaultToolCallingManager toolCallingManager = - DefaultToolCallingManager.builder().build(); + @Setter @Nullable private OrchestrationChatOptions defaultOptions; /** * Default constructor. @@ -61,6 +60,15 @@ public OrchestrationChatModel(@Nonnull final OrchestrationClient client) { this.client = client; } + @Nonnull + @Override + public ChatOptions getOptions() { + if (defaultOptions != null) { + return defaultOptions; + } + return new OrchestrationChatOptions(new OrchestrationModuleConfig()); + } + @Nonnull @Override public ChatResponse call(@Nonnull final Prompt prompt) { @@ -69,23 +77,8 @@ public ChatResponse call(@Nonnull final Prompt prompt) { val orchestrationPrompt = toOrchestrationPrompt(prompt); val response = new OrchestrationSpringChatResponse( - client.chatCompletion(orchestrationPrompt, options.getConfig())); - - if (ToolCallingChatOptions.isInternalToolExecutionEnabled(prompt.getOptions()) - && response.hasToolCalls()) { - - if (log.isDebugEnabled()) { - val tools = response.getResult().getOutput().getToolCalls(); - val toolsStr = tools.stream().map(ToolCall::name).collect(Collectors.joining(", ")); - log.debug("Executing {} tool call(s) - {}.", tools.size(), toolsStr); - } - - val toolExecutionResult = toolCallingManager.executeToolCalls(prompt, response); + client.chatCompletion(orchestrationPrompt, options.getConfigWithCallbacks())); - // Send the tool execution result back to the model. - log.debug("Re-invoking LLM with tool execution results."); - return call(new Prompt(toolExecutionResult.conversationHistory(), prompt.getOptions())); - } return response; } throw new IllegalArgumentException( diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/spring/OrchestrationChatOptions.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/spring/OrchestrationChatOptions.java index b6b32aa3a..db88c2b62 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/spring/OrchestrationChatOptions.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/spring/OrchestrationChatOptions.java @@ -7,6 +7,8 @@ import static com.sap.ai.sdk.orchestration.OrchestrationAiModel.Parameter.TOP_P; import static com.sap.ai.sdk.orchestration.OrchestrationJacksonConfiguration.getOrchestrationObjectMapper; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import com.sap.ai.sdk.orchestration.OrchestrationModuleConfig; import com.sap.ai.sdk.orchestration.model.ChatCompletionTool; @@ -14,6 +16,9 @@ import com.sap.ai.sdk.orchestration.model.FunctionObject; import com.sap.ai.sdk.orchestration.model.LLMModelDetails; import com.sap.ai.sdk.orchestration.model.Template; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; @@ -22,10 +27,9 @@ import javax.annotation.Nullable; import lombok.AccessLevel; import lombok.Data; -import lombok.Getter; +import lombok.Setter; import lombok.val; import org.springframework.ai.chat.prompt.ChatOptions; -import org.springframework.ai.model.ModelOptionsUtils; import org.springframework.ai.model.tool.ToolCallingChatOptions; import org.springframework.ai.tool.ToolCallback; @@ -35,6 +39,7 @@ * @since 1.2.0 */ @Data +@Setter(AccessLevel.NONE) public class OrchestrationChatOptions implements ToolCallingChatOptions { private static final ObjectMapper JACKSON = getOrchestrationObjectMapper(); @@ -43,10 +48,6 @@ public class OrchestrationChatOptions implements ToolCallingChatOptions { @Nonnull private List toolCallbacks = List.of(); - @Getter(AccessLevel.NONE) - @Nullable - private Boolean internalToolExecutionEnabled; - @Nonnull private Set toolNames = Set.of(); @Nonnull private Map toolContext = Map.of(); @@ -150,49 +151,195 @@ public Double getTopP() { return getLlmConfigParam(TOP_P.getName()); } - /** - * Returns a copy of this {@link OrchestrationChatOptions}. - * - * @return a copy of this {@link OrchestrationChatOptions} - */ - @SuppressWarnings("unchecked") // The same suppress is in DefaultChatOptions - @Nonnull - @Override - public T copy() { - // note: this is a shallow copy - val copyConfig = - new OrchestrationModuleConfig() - .withTemplateConfig(config.getTemplateConfig()) - .withFilteringConfig(config.getFilteringConfig()) - .withLlmConfig(config.getLlmConfig()) - .withMaskingConfig(config.getMaskingConfig()) - .withGroundingConfig(config.getGroundingConfig()); - val result = new OrchestrationChatOptions(copyConfig); - result.setToolCallbacks(toolCallbacks); - result.setInternalToolExecutionEnabled(internalToolExecutionEnabled); - return (T) result; - } - @SuppressWarnings("unchecked") @Nullable private T getLlmConfigParam(@Nonnull final String param) { return ((Map) getLlmConfigNonNull().getParams()).get(param); } + @Nonnull @Override - public void setToolCallbacks(@Nonnull final List toolCallbacks) { - this.toolCallbacks = toolCallbacks; - final Template template = - Objects.requireNonNullElse( - (Template) config.getTemplateConfig(), Template.create().template()); - val tools = toolCallbacks.stream().map(OrchestrationChatOptions::toOrchestrationTool).toList(); - config = config.withTemplateConfig(template.tools(tools)); + public Builder mutate() { + return new Builder(this); } - @Nullable - @Override - public Boolean getInternalToolExecutionEnabled() { - return this.internalToolExecutionEnabled; + /** + * Builder that preserves {@link OrchestrationChatOptions} through the Spring AI advisor chain. + * Spring AI 2.x {@code ChatClient} calls {@code mutate().build()} to reconstruct the options + * after passing through advisors; returning {@code OrchestrationChatOptions} here ensures the + * type is not lost. + * + * @since 1.25.0 + */ + public static final class Builder implements ToolCallingChatOptions.Builder { + @Nonnull private final OrchestrationChatOptions source; + @Nonnull private List toolCallbacks; + @Nonnull private Set toolNames; + @Nonnull private Map toolContext; + @Nullable private String modelName; + @Nonnull private final Map paramOverrides = new LinkedHashMap<>(); + @Nonnull private OrchestrationModuleConfig config; + + private Builder(@Nonnull final OrchestrationChatOptions source) { + this.source = source; + this.toolCallbacks = source.getToolCallbacks(); + this.toolNames = source.getToolNames(); + this.toolContext = source.getToolContext(); + this.config = source.getConfig(); + } + + @Override + @Nonnull + public Builder clone() { + return new Builder(source); + } + + @Override + @Nonnull + public Builder combineWith(@Nonnull final ChatOptions.Builder other) { + if (other instanceof OrchestrationChatOptions.Builder that) { + // Per-request builder overrides model-level defaults + this.toolCallbacks = that.toolCallbacks; + this.toolContext = that.toolContext; + // Use the per-request source for all OrchestrationChatOptions-specific config + final Builder result = new Builder(that.source); + result.toolCallbacks(this.toolCallbacks).toolContext(this.toolContext); + result.toolNames = that.toolNames; + result.modelName = that.modelName; + result.paramOverrides.putAll(that.paramOverrides); + return result; + } + return this; + } + + @Override + @Nonnull + public Builder toolCallbacks(@Nonnull final List callbacks) { + this.toolCallbacks = callbacks; + return this; + } + + @Override + @Nonnull + public Builder toolCallbacks(@Nonnull final ToolCallback... callbacks) { + this.toolCallbacks = List.of(callbacks); + return this; + } + + @Override + @Nonnull + public Builder toolContext(@Nonnull final Map ctx) { + this.toolContext = ctx; + return this; + } + + @Override + @Nonnull + public Builder toolContext(@Nonnull final String key, @Nonnull final Object value) { + val mutable = new HashMap<>(toolContext); + mutable.put(key, value); + this.toolContext = Map.copyOf(mutable); + return this; + } + + @Override + @Nonnull + public Builder model(@Nullable final String model) { + this.modelName = model; + return this; + } + + @Override + @Nonnull + public Builder frequencyPenalty(@Nullable final Double v) { + paramOverrides.put(FREQUENCY_PENALTY.getName(), v); + return this; + } + + @Override + @Nonnull + public Builder maxTokens(@Nullable final Integer v) { + paramOverrides.put(MAX_TOKENS.getName(), v); + return this; + } + + @Override + @Nonnull + public Builder presencePenalty(@Nullable final Double v) { + paramOverrides.put(PRESENCE_PENALTY.getName(), v); + return this; + } + + @Override + @Nonnull + public Builder stopSequences(@Nullable final List v) { + paramOverrides.put("stop_sequences", v); + return this; + } + + @Override + @Nonnull + public Builder temperature(@Nullable final Double v) { + paramOverrides.put(TEMPERATURE.getName(), v); + return this; + } + + @Override + @Nonnull + public Builder topK(@Nullable final Integer v) { + paramOverrides.put("top_k", v); + return this; + } + + @Override + @Nonnull + public Builder topP(@Nullable final Double v) { + paramOverrides.put(TOP_P.getName(), v); + return this; + } + + private Builder toolNames(@Nonnull final Set toolNames) { + this.toolNames = toolNames; + return this; + } + + private Builder config(@Nonnull final OrchestrationModuleConfig config) { + this.config = config; + return this; + } + + @Override + @Nonnull + public OrchestrationChatOptions build() { + val copyConfig = + new OrchestrationModuleConfig() + .withTemplateConfig(source.config.getTemplateConfig()) + .withFilteringConfig(source.config.getFilteringConfig()) + .withLlmConfig(source.config.getLlmConfig()) + .withMaskingConfig(source.config.getMaskingConfig()) + .withGroundingConfig(source.config.getGroundingConfig()); + val result = new OrchestrationChatOptions(copyConfig); + + if (modelName != null || !paramOverrides.isEmpty()) { + final LLMModelDetails existingLlm = result.getLlmConfigNonNull(); + final Map mergedParams = new LinkedHashMap<>(); + if (existingLlm.getParams() != null) { + mergedParams.putAll(existingLlm.getParams()); + } + mergedParams.putAll(paramOverrides); + final LLMModelDetails newLlm = + LLMModelDetails.create() + .name(modelName != null ? modelName : existingLlm.getName()) + .version(existingLlm.getVersion()) + .params(mergedParams); + result.config = result.getConfig().withLlmConfig(newLlm); + } + + result.toolCallbacks = toolCallbacks; + result.toolContext = toolContext; + result.toolNames = toolNames; + return result; + } } @Nonnull @@ -202,14 +349,51 @@ private LLMModelDetails getLlmConfigNonNull() { "LLM config is not set. Please set it: new OrchestrationChatOptions(new OrchestrationModuleConfig().withLlmConfig(...))"); } + /** + * Returns the config with any tool callbacks converted and injected into the template. + * + * @return the config enriched with tool definitions from {@link #getToolCallbacks()} + */ + @Nonnull + public OrchestrationModuleConfig getConfigWithCallbacks() { + if (toolCallbacks.isEmpty()) { + return config; + } + final List converted = + toolCallbacks.stream().map(OrchestrationChatOptions::toOrchestrationTool).toList(); + final var existingTemplate = config.getTemplateConfig() instanceof Template t ? t : null; + final var mergedTools = new ArrayList(); + if (existingTemplate != null && existingTemplate.getTools() != null) { + mergedTools.addAll(existingTemplate.getTools()); + } + mergedTools.addAll(converted); + final Template newTemplate = Template.create().template(List.of()).tools(mergedTools); + if (existingTemplate != null) { + if (existingTemplate.getTemplate() != null) { + newTemplate.template(existingTemplate.getTemplate()); + } + if (existingTemplate.getDefaults() != null) { + newTemplate.defaults(existingTemplate.getDefaults()); + } + } + return config.withTemplateConfig(newTemplate); + } + private static ChatCompletionTool toOrchestrationTool(@Nonnull final ToolCallback toolCallback) { val toolDef = toolCallback.getToolDefinition(); - return ChatCompletionTool.create() - .type(TypeEnum.FUNCTION) - .function( - FunctionObject.create() - .name(toolDef.name()) - .description(toolDef.description()) - .parameters(ModelOptionsUtils.jsonToMap(toolDef.inputSchema()))); + try { + final Map params = + JACKSON.readValue(toolDef.inputSchema(), new TypeReference<>() {}); + return ChatCompletionTool.create() + .type(TypeEnum.FUNCTION) + .function( + FunctionObject.create() + .name(toolDef.name()) + .description(toolDef.description()) + .parameters(params)); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException( + "Failed to parse tool input schema for tool: " + toolDef.name(), e); + } } } diff --git a/orchestration/src/test/java/com/sap/ai/sdk/orchestration/TextItemTest.java b/orchestration/src/test/java/com/sap/ai/sdk/orchestration/TextItemTest.java new file mode 100644 index 000000000..6cb7d7321 --- /dev/null +++ b/orchestration/src/test/java/com/sap/ai/sdk/orchestration/TextItemTest.java @@ -0,0 +1,20 @@ +package com.sap.ai.sdk.orchestration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +class TextItemTest { + + @Test + void testEquals() { + assertThat(new TextItem("test").equals(null)).isFalse(); + } + + @Test + void testHashCode() { + assertThat(new TextItem("test").hashCode()).isEqualTo(new TextItem("test").hashCode()); + assertThat(new TextItem("test").hashCode()).isNotEqualTo(new TextItem("other").hashCode()); + } +} diff --git a/orchestration/src/test/java/com/sap/ai/sdk/orchestration/spring/MockWeatherService.java b/orchestration/src/test/java/com/sap/ai/sdk/orchestration/spring/MockWeatherService.java deleted file mode 100644 index 46c79bb3c..000000000 --- a/orchestration/src/test/java/com/sap/ai/sdk/orchestration/spring/MockWeatherService.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.sap.ai.sdk.orchestration.spring; - -import java.util.function.Function; -import javax.annotation.Nonnull; - -/** Function for tool calls in Spring AI */ -public class MockWeatherService - implements Function { - - /** Unit of temperature */ - public enum Unit { - /** Celsius */ - C, - /** Fahrenheit */ - F - } - - /** - * Request for the weather - * - * @param location the city - * @param unit the unit of temperature - */ - public record Request(String location, Unit unit) {} - - /** - * Response for the weather - * - * @param temp the temperature - * @param unit the unit of temperature - */ - public record Response(double temp, Unit unit) {} - - /** - * Apply the function - * - * @param request the request - * @return the response - */ - @Nonnull - public Response apply(@Nonnull Request request) { - return new Response(30.0, Unit.C); - } -} diff --git a/orchestration/src/test/java/com/sap/ai/sdk/orchestration/spring/OrchestrationChatModelTest.java b/orchestration/src/test/java/com/sap/ai/sdk/orchestration/spring/OrchestrationChatModelTest.java index 17b1eaa82..1d1cd2d72 100644 --- a/orchestration/src/test/java/com/sap/ai/sdk/orchestration/spring/OrchestrationChatModelTest.java +++ b/orchestration/src/test/java/com/sap/ai/sdk/orchestration/spring/OrchestrationChatModelTest.java @@ -27,7 +27,6 @@ import com.sap.cloud.sdk.cloudplatform.connectivity.DefaultHttpDestination; import java.io.IOException; import java.io.InputStream; -import java.util.List; import java.util.Objects; import java.util.function.Function; import lombok.val; @@ -44,7 +43,6 @@ import org.springframework.ai.chat.memory.ChatMemory; import org.springframework.ai.chat.memory.InMemoryChatMemoryRepository; import org.springframework.ai.chat.memory.MessageWindowChatMemory; -import org.springframework.ai.chat.messages.AssistantMessage.ToolCall; import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.chat.prompt.Prompt; import org.springframework.ai.support.ToolCallbacks; @@ -67,6 +65,7 @@ void setup(WireMockRuntimeInfo server) { client = new OrchestrationChatModel(new OrchestrationClient(destination)); defaultOptions = new OrchestrationChatOptions(new OrchestrationModuleConfig().withLlmConfig(GPT_4O)); + client.setDefaultOptions(defaultOptions); prompt = new Prompt("Hello World! Why is this phrase so famous?", defaultOptions); ApacheHttpClient5Accessor.setHttpClientCache(ApacheHttpClient5Cache.DISABLED); } @@ -148,38 +147,7 @@ void testStreamCompletion() throws IOException { } @Test - void testToolCallsWithoutExecution() throws IOException { - stubFor( - post(urlPathEqualTo("/v2/completion")) - .willReturn( - aResponse() - .withBodyFile("toolCallsResponse.json") - .withHeader("Content-Type", "application/json"))); - - defaultOptions.setToolCallbacks(List.of(ToolCallbacks.from(new WeatherMethod()))); - defaultOptions.setInternalToolExecutionEnabled(false); - val prompt = new Prompt("What is the weather in Potsdam and in Toulouse?", defaultOptions); - val result = client.call(prompt); - - List toolCalls = result.getResult().getOutput().getToolCalls(); - assertThat(toolCalls).hasSize(2); - ToolCall toolCall1 = toolCalls.get(0); - ToolCall toolCall2 = toolCalls.get(1); - assertThat(toolCall1.type()).isEqualTo("function"); - assertThat(toolCall2.type()).isEqualTo("function"); - assertThat(toolCall1.name()).isEqualTo("getCurrentWeather"); - assertThat(toolCall2.name()).isEqualTo("getCurrentWeather"); - assertThat(toolCall1.arguments()).isEqualTo("{\"arg0\": \"Potsdam\", \"arg1\": \"C\"}"); - assertThat(toolCall2.arguments()).isEqualTo("{\"arg0\": \"Toulouse\", \"arg1\": \"C\"}"); - - try (var request1InputStream = fileLoader.apply("toolCallsRequest.json")) { - final String request1 = new String(request1InputStream.readAllBytes()); - verify(postRequestedFor(anyUrl()).withRequestBody(equalToJson(request1))); - } - } - - @Test - void testToolCallsWithExecution() throws IOException { + void testToolCallsExecution() throws IOException { // https://platform.openai.com/docs/guides/function-calling stubFor( post(urlPathEqualTo("/v2/completion")) @@ -199,10 +167,13 @@ void testToolCallsWithExecution() throws IOException { aResponse() .withBodyFile("toolCallsResponse2.json") .withHeader("Content-Type", "application/json"))); - - defaultOptions.setToolCallbacks(List.of(ToolCallbacks.from(new WeatherMethod()))); - val prompt = new Prompt("What is the weather in Potsdam and in Toulouse?", defaultOptions); - val result = client.call(prompt); + val options = + defaultOptions.mutate().toolCallbacks(ToolCallbacks.from(new WeatherMethod())).build(); + val prompt = new Prompt("What is the weather in Potsdam and in Toulouse?", options); + val result = + Objects.requireNonNull( + ChatClient.builder(client).build().prompt(prompt).call().chatResponse(), + "Chat response is null"); assertThat(result.getResult().getOutput().getText()) .isEqualTo("The current temperature in Potsdam is 30°C and in Toulouse 30°C."); @@ -241,9 +212,13 @@ void testChatMemory() throws IOException { val repository = new InMemoryChatMemoryRepository(); val memory = MessageWindowChatMemory.builder().chatMemoryRepository(repository).build(); val advisor = MessageChatMemoryAdvisor.builder(memory).build(); - val cl = ChatClient.builder(client).defaultAdvisors(advisor).build(); - val prompt1 = new Prompt("What is the capital of France?", defaultOptions); - val prompt2 = new Prompt("And what is the typical food there?", defaultOptions); + val cl = + ChatClient.builder(client) + .defaultAdvisors(advisor) + .defaultOptions(defaultOptions.mutate()) + .build(); + val prompt1 = new Prompt("What is the capital of France?"); + val prompt2 = new Prompt("And what is the typical food there?"); cl.prompt(prompt1) .advisors(spec -> spec.param(ChatMemory.CONVERSATION_ID, "test-conversation")) diff --git a/orchestration/src/test/java/com/sap/ai/sdk/orchestration/spring/OrchestrationChatOptionsTest.java b/orchestration/src/test/java/com/sap/ai/sdk/orchestration/spring/OrchestrationChatOptionsTest.java index a9e7cf90a..ef161e5fe 100644 --- a/orchestration/src/test/java/com/sap/ai/sdk/orchestration/spring/OrchestrationChatOptionsTest.java +++ b/orchestration/src/test/java/com/sap/ai/sdk/orchestration/spring/OrchestrationChatOptionsTest.java @@ -1,6 +1,7 @@ package com.sap.ai.sdk.orchestration.spring; import static com.sap.ai.sdk.orchestration.OrchestrationAiModel.GEMINI_2_5_FLASH; +import static com.sap.ai.sdk.orchestration.OrchestrationAiModel.GPT_4O; import static com.sap.ai.sdk.orchestration.OrchestrationAiModel.Parameter.FREQUENCY_PENALTY; import static com.sap.ai.sdk.orchestration.OrchestrationAiModel.Parameter.MAX_TOKENS; import static com.sap.ai.sdk.orchestration.OrchestrationAiModel.Parameter.PRESENCE_PENALTY; @@ -10,8 +11,12 @@ import com.sap.ai.sdk.orchestration.OrchestrationAiModel; import com.sap.ai.sdk.orchestration.OrchestrationModuleConfig; +import com.sap.ai.sdk.orchestration.TemplateConfig; +import com.sap.ai.sdk.orchestration.model.Template; import java.util.List; +import java.util.Map; import org.junit.jupiter.api.Test; +import org.springframework.ai.support.ToolCallbacks; class OrchestrationChatOptionsTest { @@ -37,6 +42,11 @@ private static void assertCustomLLM(OrchestrationChatOptions opts) { assertThat(opts.getTopP()).isEqualTo(0.5); } + private static OrchestrationChatOptions baseOpts() { + return new OrchestrationChatOptions( + new OrchestrationModuleConfig().withLlmConfig(GEMINI_2_5_FLASH)); + } + @Test void testParametersAreInherited() { var opts = @@ -56,22 +66,241 @@ void testCustomParametersAreInherited() { } @Test - void testCopy() { + void testMutateAndBuild() { var opts = new OrchestrationChatOptions( new OrchestrationModuleConfig().withLlmConfig(GEMINI_2_5_FLASH)); - var copy = (OrchestrationChatOptions) opts.copy(); + var copy = opts.mutate().build(); assertThat(copy.getModel()).isEqualTo(GEMINI_2_5_FLASH.getName()); assertThat(copy.getModelVersion()).isEqualTo(GEMINI_2_5_FLASH.getVersion()); } @Test - void testCustomCopy() { + void testMutateAndBuildCustom() { var opts = new OrchestrationChatOptions(new OrchestrationModuleConfig().withLlmConfig(CUSTOM_LLM)); - var copy = (OrchestrationChatOptions) opts.copy(); + var copy = opts.mutate().build(); assertCustomLLM(copy); } + + @Test + void testBuilderModelOverride() { + var built = baseOpts().mutate().model(GPT_4O.getName()).build(); + + assertThat(built.getModel()).isEqualTo(GPT_4O.getName()); + // other fields from source are preserved + assertThat(built.getModelVersion()).isEqualTo(GEMINI_2_5_FLASH.getVersion()); + } + + @Test + void testBuilderFrequencyPenalty() { + var built = baseOpts().mutate().frequencyPenalty(0.7).build(); + + assertThat(built.getFrequencyPenalty()).isEqualTo(0.7); + } + + @Test + void testBuilderMaxTokens() { + var built = baseOpts().mutate().maxTokens(200).build(); + + assertThat(built.getMaxTokens()).isEqualTo(200); + } + + @Test + void testBuilderPresencePenalty() { + var built = baseOpts().mutate().presencePenalty(0.3).build(); + + assertThat(built.getPresencePenalty()).isEqualTo(0.3); + } + + @Test + void testBuilderStopSequences() { + var built = baseOpts().mutate().stopSequences(List.of("stop", "end")).build(); + + assertThat(built.getStopSequences()).containsExactly("stop", "end"); + } + + @Test + void testBuilderTemperature() { + var built = baseOpts().mutate().temperature(0.9).build(); + + assertThat(built.getTemperature()).isEqualTo(0.9); + } + + @Test + void testBuilderTopK() { + var built = baseOpts().mutate().topK(40).build(); + + assertThat(built.getTopK()).isEqualTo(40); + } + + @Test + void testBuilderTopP() { + var built = baseOpts().mutate().topP(0.8).build(); + + assertThat(built.getTopP()).isEqualTo(0.8); + } + + @Test + void testBuilderAllScalarsAtOnce() { + var built = + baseOpts() + .mutate() + .model(GPT_4O.getName()) + .frequencyPenalty(0.1) + .maxTokens(50) + .presencePenalty(0.2) + .stopSequences(List.of("\n")) + .temperature(0.6) + .topK(10) + .topP(0.95) + .build(); + + assertThat(built.getModel()).isEqualTo(GPT_4O.getName()); + assertThat(built.getFrequencyPenalty()).isEqualTo(0.1); + assertThat(built.getMaxTokens()).isEqualTo(50); + assertThat(built.getPresencePenalty()).isEqualTo(0.2); + assertThat(built.getStopSequences()).containsExactly("\n"); + assertThat(built.getTemperature()).isEqualTo(0.6); + assertThat(built.getTopK()).isEqualTo(10); + assertThat(built.getTopP()).isEqualTo(0.95); + } + + @Test + void testBuilderOverridesPreserveExistingParams() { + // Source already has all params; builder should override only the ones specified + var source = + new OrchestrationChatOptions(new OrchestrationModuleConfig().withLlmConfig(CUSTOM_LLM)); + var built = source.mutate().temperature(0.99).build(); + + assertThat(built.getTemperature()).isEqualTo(0.99); + // other params unchanged from CUSTOM_LLM + assertThat(built.getMaxTokens()).isEqualTo(100); + assertThat(built.getFrequencyPenalty()).isEqualTo(0.5); + assertThat(built.getModel()).isEqualTo(GEMINI_2_5_FLASH.getName()); + } + + @Test + void testBuilderDoesNotMutateSource() { + var source = baseOpts(); + source.mutate().temperature(0.5).maxTokens(100).build(); + + // source must be unchanged + assertThat(source.getTemperature()).isNull(); + assertThat(source.getMaxTokens()).isNull(); + } + + @Test + void testBuilderToolCallbacks() { + var callbacks = ToolCallbacks.from(new WeatherMethod()); + var built = baseOpts().mutate().toolCallbacks(List.of(callbacks)).build(); + + // The built result has the tool callbacks set (setToolCallbacks wires them into template config + // too) + assertThat(built.getToolCallbacks()).hasSize(1); + } + + @Test + void testBuilderToolContext() { + var built = baseOpts().mutate().toolContext("key", "value").build(); + + assertThat(built.getToolContext()).containsEntry("key", "value"); + } + + @Test + void testBuilderToolContextMap() { + var built = baseOpts().mutate().toolContext(Map.of("a", 1, "b", 2)).build(); + + assertThat(built.getToolContext()).containsEntry("a", 1).containsEntry("b", 2); + } + + @Test + void testCombineWithOrchestrationBuilder() { + var base = baseOpts(); + var perRequest = + new OrchestrationChatOptions(new OrchestrationModuleConfig().withLlmConfig(GPT_4O)); + + // Simulate what Spring AI does: starts from base.mutate(), then combines with + // per-request.mutate() + var combined = base.mutate().combineWith(perRequest.mutate().temperature(0.7)); + + var result = combined.build(); + // Per-request source (GPT_4O) wins for OrchestrationChatOptions-specific config + assertThat(result.getModel()).isEqualTo(GPT_4O.getName()); + // Per-request temperature override is carried through + assertThat(result.getTemperature()).isEqualTo(0.7); + } + + @Test + void testCombineWithNonOrchestrationBuilderIsNoOp() { + var base = baseOpts().mutate().temperature(0.4); + var unrelated = org.springframework.ai.chat.prompt.ChatOptions.builder().temperature(0.9); + + var result = base.combineWith(unrelated).build(); + + // combineWith a non-OrchestrationChatOptions.Builder is a no-op; base values survive + assertThat(result.getModel()).isEqualTo(GEMINI_2_5_FLASH.getName()); + assertThat(result.getTemperature()).isEqualTo(0.4); + } + + @Test + void testMutateProducesOrchestrationChatOptions() { + var opts = baseOpts(); + var builder = opts.mutate(); + var result = builder.build(); + + assertThat(result).isInstanceOf(OrchestrationChatOptions.class); + assertThat(result.getModel()).isEqualTo(GEMINI_2_5_FLASH.getName()); + } + + @Test + void testGetConfigWithCallbacksNoCallbacks() { + var opts = baseOpts(); + var config = opts.getConfigWithCallbacks(); + + assertThat(config).isSameAs(opts.getConfig()); + } + + @Test + void testGetConfigWithCallbacksInjectsTools() { + var opts = + baseOpts().mutate().toolCallbacks(List.of(ToolCallbacks.from(new WeatherMethod()))).build(); + var config = opts.getConfigWithCallbacks(); + + assertThat(config).isNotSameAs(opts.getConfig()); + var template = (Template) config.getTemplateConfig(); + assertThat(template.getTools()).hasSize(1); + assertThat(template.getTools().get(0).getFunction().getName()).isEqualTo("getCurrentWeather"); + } + + @Test + void testGetConfigWithCallbacksMergesWithExistingTools() { + var existingTemplate = TemplateConfig.create().withTools(List.of()); + var configWithTemplate = + new OrchestrationModuleConfig() + .withLlmConfig(GEMINI_2_5_FLASH) + .withTemplateConfig(existingTemplate); + var opts = + new OrchestrationChatOptions(configWithTemplate) + .mutate() + .toolCallbacks(List.of(ToolCallbacks.from(new WeatherMethod()))) + .build(); + + var config = opts.getConfigWithCallbacks(); + + var template = (Template) config.getTemplateConfig(); + assertThat(template.getTools()).hasSize(1); + assertThat(template.getTools().get(0).getFunction().getName()).isEqualTo("getCurrentWeather"); + } + + @Test + void testGetConfigWithCallbacksDoesNotMutateOriginalConfig() { + var opts = + baseOpts().mutate().toolCallbacks(List.of(ToolCallbacks.from(new WeatherMethod()))).build(); + opts.getConfigWithCallbacks(); + + assertThat(opts.getConfig().getTemplateConfig()).isNull(); + } } diff --git a/pom.xml b/pom.xml index a81a8fda3..5e446c53d 100644 --- a/pom.xml +++ b/pom.xml @@ -65,7 +65,7 @@ 14.0.0 2.1.3 3.5.6 - 1.1.8 + 2.0.1 7.0.9 7.0.9 7.0.9 @@ -76,14 +76,15 @@ 3.2.0 5.23.0 3.28.2 - 4.38.0 + 5.0.0 + 5.0.0 2.22.2 2.22 3.2.2 1.6.3 1.22.1 0.26.1 - 4.52.0 + 4.54.0 5.4.3 5.6.4 @@ -91,6 +92,7 @@ 20260814 2.6 1.0.1 + 3.0.1 3.10.0 false @@ -160,6 +162,11 @@ jackson-datatype-jsr ${jackson.version} + + com.networknt + json-schema-validator + ${json-schema-validator.version} + com.fasterxml.jackson.datatype jackson-datatype-jsr310 @@ -195,6 +202,11 @@ jsonschema-module-jackson ${jsonschema-generator.version} + + com.github.victools + jsonschema-module-swagger-2 + ${jsonschema-module-swagger.version} + com.fasterxml.jackson.dataformat jackson-dataformat-yaml diff --git a/sample-code/spring-app/pom.xml b/sample-code/spring-app/pom.xml index 47b2083a1..4591f827b 100644 --- a/sample-code/spring-app/pom.xml +++ b/sample-code/spring-app/pom.xml @@ -36,7 +36,6 @@ 4.1.1 4.2.0 11.0.25 - 2.0.1 2.22 true @@ -59,12 +58,6 @@ tomcat-embed-websocket ${apache-tomcat-embed.version} - - - io.modelcontextprotocol.sdk - mcp-core - ${mcp-core.version} - org.junit @@ -169,25 +162,6 @@ - - io.modelcontextprotocol.sdk - mcp-core - runtime - - - org.springframework.ai - spring-ai-autoconfigure-mcp-client - 1.0.9 - runtime - - - - org.springframework.boot - spring-boot-starter - - - org.springframework.boot spring-boot-autoconfigure diff --git a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/SpringAiAgenticWorkflowService.java b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/SpringAiAgenticWorkflowService.java index 9cb0fd3d7..ac288fbff 100644 --- a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/SpringAiAgenticWorkflowService.java +++ b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/SpringAiAgenticWorkflowService.java @@ -46,9 +46,11 @@ public ChatResponse runAgent(@Nonnull final String userInput) { val cl = ChatClient.builder(client).defaultAdvisors(advisor).build(); // Add (mocked) tools - val options = new OrchestrationChatOptions(config); - options.setToolCallbacks( - List.of(ToolCallbacks.from(new WeatherMethod(), new RestaurantMethod()))); + val options = + new OrchestrationChatOptions(config) + .mutate() + .toolCallbacks(ToolCallbacks.from(new WeatherMethod(), new RestaurantMethod())) + .build(); // Prompts for the chain workflow final List systemPrompts = diff --git a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/SpringAiOpenAiService.java b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/SpringAiOpenAiService.java index 48796e833..94d912200 100644 --- a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/SpringAiOpenAiService.java +++ b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/SpringAiOpenAiService.java @@ -91,11 +91,16 @@ public Flux streamChatCompletion() { */ @Nonnull public ChatResponse toolCalling(final boolean internalToolExecutionEnabled) { - val options = new DefaultToolCallingChatOptions(); - options.setToolCallbacks(List.of(ToolCallbacks.from(new WeatherMethod()))); - options.setInternalToolExecutionEnabled(internalToolExecutionEnabled); - + val options = + DefaultToolCallingChatOptions.builder() + .toolCallbacks(ToolCallbacks.from(new WeatherMethod())) + .build(); val prompt = new Prompt("What is the weather in Potsdam and in Toulouse?", options); + if (internalToolExecutionEnabled) { + return Objects.requireNonNull( + ChatClient.builder(chatClient).build().prompt(prompt).call().chatResponse(), + "Chat response is null"); + } return chatClient.call(prompt); } diff --git a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/SpringAiOrchestrationService.java b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/SpringAiOrchestrationService.java index 62c9cdc8a..cd896326a 100644 --- a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/SpringAiOrchestrationService.java +++ b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/SpringAiOrchestrationService.java @@ -176,11 +176,18 @@ public ChatResponse outputFiltering(@Nonnull final AzureFilterThreshold policy) */ @Nonnull public ChatResponse toolCalling(final boolean internalToolExecutionEnabled) { - val options = new OrchestrationChatOptions(config); - options.setToolCallbacks(List.of(ToolCallbacks.from(new WeatherMethod()))); - options.setInternalToolExecutionEnabled(internalToolExecutionEnabled); + val options = + new OrchestrationChatOptions(config) + .mutate() + .toolCallbacks(ToolCallbacks.from(new WeatherMethod())) + .build(); val prompt = new Prompt("What is the weather in Potsdam and in Toulouse?", options); + if (internalToolExecutionEnabled) { + return Objects.requireNonNull( + ChatClient.builder(client).build().prompt(prompt).call().chatResponse(), + "Chat response is null"); + } return client.call(prompt); } @@ -202,8 +209,11 @@ public ChatResponse toolCallingMcp() { "No MCP clients were found. Ensure that you configured the clients correctly in the application.yaml file."); } // GPT-4o-mini doesn't work too well with the file system tool, so we use 4o here - val options = new OrchestrationChatOptions(config.withLlmConfig(GPT_4O)); - options.setToolCallbacks(List.of(toolCallbackProvider.getToolCallbacks())); + val options = + new OrchestrationChatOptions(config.withLlmConfig(GPT_4O)) + .mutate() + .toolCallbacks(toolCallbackProvider.getToolCallbacks()) + .build(); val sys = new SystemMessage(