diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml index 0e7e61203..422d1887b 100644 --- a/.github/dependabot.yaml +++ b/.github/dependabot.yaml @@ -22,8 +22,6 @@ updates: 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 60592c9ae..769de1b19 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 5745d6681..108feb04e 100644 --- a/docs/release_notes.md +++ b/docs/release_notes.md @@ -8,7 +8,7 @@ ### 🔧 Compatibility Notes -- +-[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 7e6e9f9af..c1d2a0c26 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 8b7b07bae..e27cff450 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..a60f0759c 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 @@ -73,8 +73,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 (com.fasterxml.jackson.core.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..9dc1eaded 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; @@ -17,6 +18,8 @@ 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 +27,10 @@ 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 org.springframework.ai.model.tool.ToolExecutionResult; import reactor.core.publisher.Flux; /** @@ -38,6 +42,8 @@ public class OrchestrationChatModel implements ChatModel { @Nonnull private final OrchestrationClient client; + @Setter @Nullable private OrchestrationChatOptions defaultOptions; + @Nonnull private final DefaultToolCallingManager toolCallingManager = DefaultToolCallingManager.builder().build(); @@ -61,6 +67,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) { @@ -71,7 +86,7 @@ public ChatResponse call(@Nonnull final Prompt prompt) { new OrchestrationSpringChatResponse( client.chatCompletion(orchestrationPrompt, options.getConfig())); - if (ToolCallingChatOptions.isInternalToolExecutionEnabled(prompt.getOptions()) + if (!Boolean.FALSE.equals(options.isInternalToolExecutionEnabled()) && response.hasToolCalls()) { if (log.isDebugEnabled()) { @@ -82,6 +97,11 @@ public ChatResponse call(@Nonnull final Prompt prompt) { val toolExecutionResult = toolCallingManager.executeToolCalls(prompt, response); + if (toolExecutionResult.returnDirect()) { + log.debug("Returning tool execution result directly without re-invoking LLM."); + return new ChatResponse(ToolExecutionResult.buildGenerations(toolExecutionResult)); + } + // 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())); 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..506bca322 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,7 @@ 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.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import com.sap.ai.sdk.orchestration.OrchestrationModuleConfig; import com.sap.ai.sdk.orchestration.model.ChatCompletionTool; @@ -25,7 +26,6 @@ import lombok.Getter; 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; @@ -153,11 +153,11 @@ public Double getTopP() { /** * Returns a copy of this {@link OrchestrationChatOptions}. * + * @param option subtype * @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 = @@ -169,6 +169,7 @@ public T copy() { .withGroundingConfig(config.getGroundingConfig()); val result = new OrchestrationChatOptions(copyConfig); result.setToolCallbacks(toolCallbacks); + result.setToolNames(toolNames); result.setInternalToolExecutionEnabled(internalToolExecutionEnabled); return (T) result; } @@ -179,7 +180,11 @@ private T getLlmConfigParam(@Nonnull final String param) { return ((Map) getLlmConfigNonNull().getParams()).get(param); } - @Override + /** + * Setter method + * + * @param toolCallbacks tool callbacks to set int template config + */ public void setToolCallbacks(@Nonnull final List toolCallbacks) { this.toolCallbacks = toolCallbacks; final Template template = @@ -189,12 +194,180 @@ public void setToolCallbacks(@Nonnull final List toolCallbacks) { config = config.withTemplateConfig(template.tools(tools)); } + /** + * Getter method + * + * @return if internal tool execution enabled + */ @Nullable - @Override - public Boolean getInternalToolExecutionEnabled() { + public Boolean isInternalToolExecutionEnabled() { return this.internalToolExecutionEnabled; } + @Nonnull + @Override + public Builder mutate() { + return new Builder(this); + } + + /** + * 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 java.util.LinkedHashMap<>(); + + private Builder(@Nonnull final OrchestrationChatOptions source) { + this.source = source; + this.toolCallbacks = source.getToolCallbacks(); + this.toolNames = source.getToolNames(); + this.toolContext = source.getToolContext(); + } + + @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 java.util.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; + } + + @Override + @Nonnull + public OrchestrationChatOptions build() { + final OrchestrationChatOptions result = source.copy(); + if (modelName != null || !paramOverrides.isEmpty()) { + final LLMModelDetails existingLlm = result.getLlmConfigNonNull(); + final Map mergedParams = new java.util.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.setConfig(result.getConfig().withLlmConfig(newLlm)); + } + result.setToolCallbacks(toolCallbacks); + result.setToolNames(toolNames); + result.setToolContext(toolContext); + return result; + } + } + @Nonnull private LLMModelDetails getLlmConfigNonNull() { return Objects.requireNonNull( @@ -204,12 +377,19 @@ private LLMModelDetails getLlmConfigNonNull() { 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 (com.fasterxml.jackson.core.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..b4a001407 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 @@ -67,6 +67,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); } @@ -241,9 +242,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..a500641cb 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; @@ -11,7 +12,9 @@ import com.sap.ai.sdk.orchestration.OrchestrationAiModel; import com.sap.ai.sdk.orchestration.OrchestrationModuleConfig; import java.util.List; +import java.util.Map; import org.junit.jupiter.api.Test; +import org.springframework.ai.support.ToolCallbacks; class OrchestrationChatOptionsTest { @@ -37,6 +40,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 = @@ -74,4 +82,174 @@ void testCustomCopy() { var copy = (OrchestrationChatOptions) opts.copy(); 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()); + } } diff --git a/pom.xml b/pom.xml index cf299ec22..4d0e93ad0 100644 --- a/pom.xml +++ b/pom.xml @@ -65,12 +65,12 @@ 14.0.0 2.1.3 3.5.6 - 1.1.8 + 2.0.1 3.8.7 3.2.0 5.23.0 3.28.2 - 4.38.0 + 5.0.0 2.22.2 2.22 3.2.2 diff --git a/sample-code/spring-app/pom.xml b/sample-code/spring-app/pom.xml index dce6a46b4..0bd6ad5f6 100644 --- a/sample-code/spring-app/pom.xml +++ b/sample-code/spring-app/pom.xml @@ -65,6 +65,18 @@ mcp-core ${mcp-core.version} + + + com.github.victools + jsonschema-module-swagger-2 + 5.0.0 + + + + com.networknt + json-schema-validator + 3.0.1 + org.junit @@ -176,17 +188,15 @@ org.springframework.ai - spring-ai-autoconfigure-mcp-client - 1.0.9 + spring-ai-autoconfigure-mcp-client-common + 2.0.1 + runtime + + + org.springframework.ai + spring-ai-mcp-annotations + 2.0.1 runtime - - - - org.springframework.boot - spring-boot-starter - - org.springframework.boot 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); }