Skip to content

Commit 1b18954

Browse files
authored
Merge branch 'spring-projects:main' into fan
2 parents beaf047 + 5aa8940 commit 1b18954

File tree

15 files changed

+485
-35
lines changed

15 files changed

+485
-35
lines changed

.github/workflows/backport-issue.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,6 @@ on:
77

88
jobs:
99
backport-issue:
10-
uses: spring-io/spring-github-workflows/.github/workflows/spring-backport-issue.yml@v5
10+
uses: spring-io/spring-github-workflows/.github/workflows/spring-backport-issue.yml@main
1111
secrets:
1212
GH_ACTIONS_REPO_TOKEN: ${{ secrets.GH_ACTIONS_REPO_TOKEN }}

auto-configurations/models/spring-ai-autoconfigure-model-bedrock-ai/src/main/java/org/springframework/ai/model/bedrock/converse/autoconfigure/BedrockConverseProxyChatProperties.java

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,7 @@ public class BedrockConverseProxyChatProperties {
3333
public static final String CONFIG_PREFIX = "spring.ai.bedrock.converse.chat";
3434

3535
@NestedConfigurationProperty
36-
private ToolCallingChatOptions options = ToolCallingChatOptions.builder()
37-
.temperature(0.7)
38-
.maxTokens(300)
39-
.topK(10)
40-
.build();
36+
private ToolCallingChatOptions options = ToolCallingChatOptions.builder().temperature(0.7).maxTokens(300).build();
4137

4238
public ToolCallingChatOptions getOptions() {
4339
return this.options;

auto-configurations/models/tool/spring-ai-autoconfigure-model-tool/src/main/java/org/springframework/ai/model/tool/autoconfigure/ToolCallingAutoConfiguration.java

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,9 @@
1616

1717
package org.springframework.ai.model.tool.autoconfigure;
1818

19+
import io.micrometer.observation.ObservationRegistry;
1920
import java.util.ArrayList;
2021
import java.util.List;
21-
22-
import io.micrometer.observation.ObservationRegistry;
2322
import org.slf4j.Logger;
2423
import org.slf4j.LoggerFactory;
2524

@@ -43,12 +42,14 @@
4342
import org.springframework.boot.context.properties.EnableConfigurationProperties;
4443
import org.springframework.context.annotation.Bean;
4544
import org.springframework.context.support.GenericApplicationContext;
45+
import org.springframework.util.ClassUtils;
4646

4747
/**
4848
* Auto-configuration for common tool calling features of {@link ChatModel}.
4949
*
5050
* @author Thomas Vitale
5151
* @author Christian Tzolov
52+
* @author Daniel Garnier-Moiroux
5253
* @since 1.0.0
5354
*/
5455
@AutoConfiguration
@@ -78,7 +79,21 @@ ToolCallbackResolver toolCallbackResolver(GenericApplicationContext applicationC
7879
@Bean
7980
@ConditionalOnMissingBean
8081
ToolExecutionExceptionProcessor toolExecutionExceptionProcessor(ToolCallingProperties properties) {
81-
return new DefaultToolExecutionExceptionProcessor(properties.isThrowExceptionOnError());
82+
ArrayList<Class<? extends RuntimeException>> rethrownExceptions = new ArrayList<>();
83+
84+
// ClientAuthorizationException is used by Spring Security in oauth2 flows,
85+
// for example with ServletOAuth2AuthorizedClientExchangeFilterFunction and
86+
// OAuth2ClientHttpRequestInterceptor.
87+
Class<? extends RuntimeException> oauth2Exception = getClassOrNull(
88+
"org.springframework.security.oauth2.client.ClientAuthorizationException");
89+
if (oauth2Exception != null) {
90+
rethrownExceptions.add(oauth2Exception);
91+
}
92+
93+
return DefaultToolExecutionExceptionProcessor.builder()
94+
.alwaysThrow(properties.isThrowExceptionOnError())
95+
.rethrowExceptions(rethrownExceptions)
96+
.build();
8297
}
8398

8499
@Bean
@@ -108,4 +123,14 @@ ToolCallingContentObservationFilter toolCallingContentObservationFilter() {
108123
return new ToolCallingContentObservationFilter();
109124
}
110125

126+
private static Class<? extends RuntimeException> getClassOrNull(String className) {
127+
try {
128+
return (Class<? extends RuntimeException>) ClassUtils.forName(className, null);
129+
}
130+
catch (ClassNotFoundException e) {
131+
logger.debug("Cannot load class", e);
132+
}
133+
return null;
134+
}
135+
111136
}

models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/api/AnthropicApi.java

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1060,8 +1060,7 @@ public List<ContentBlockStartEvent.ContentBlockToolUse> getToolContentBlocks() {
10601060
* @return True if the event is empty, false otherwise.
10611061
*/
10621062
public boolean isEmpty() {
1063-
return (this.index == null || this.id == null || this.name == null
1064-
|| !StringUtils.hasText(this.partialJson));
1063+
return (this.index == null || this.id == null || this.name == null);
10651064
}
10661065

10671066
ToolUseAggregationEvent withIndex(Integer index) {

models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/client/AnthropicChatClientMethodInvokingFunctionCallbackIT.java

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,18 +19,23 @@
1919
import java.util.List;
2020
import java.util.Map;
2121
import java.util.concurrent.ConcurrentHashMap;
22+
import java.util.stream.Collectors;
2223

2324
import org.junit.jupiter.api.BeforeEach;
2425
import org.junit.jupiter.api.Test;
2526
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
27+
import org.junit.jupiter.params.ParameterizedTest;
28+
import org.junit.jupiter.params.provider.ValueSource;
2629
import org.slf4j.Logger;
2730
import org.slf4j.LoggerFactory;
2831

2932
import org.springframework.ai.anthropic.AnthropicTestConfiguration;
3033
import org.springframework.ai.chat.client.ChatClient;
3134
import org.springframework.ai.chat.messages.Message;
3235
import org.springframework.ai.chat.model.ChatModel;
36+
import org.springframework.ai.chat.model.ChatResponse;
3337
import org.springframework.ai.chat.model.ToolContext;
38+
import org.springframework.ai.model.tool.ToolCallingChatOptions;
3439
import org.springframework.ai.tool.annotation.Tool;
3540
import org.springframework.ai.tool.method.MethodToolCallback;
3641
import org.springframework.ai.tool.support.ToolDefinitions;
@@ -39,6 +44,8 @@
3944
import org.springframework.test.context.ActiveProfiles;
4045
import org.springframework.util.ReflectionUtils;
4146

47+
import reactor.core.publisher.Flux;
48+
4249
import static org.assertj.core.api.Assertions.assertThat;
4350
import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy;
4451

@@ -262,6 +269,39 @@ void toolAnnotation() {
262269
.containsEntry("color", TestFunctionClass.LightColor.RED);
263270
}
264271

272+
// https://github.com/spring-projects/spring-ai/issues/1878
273+
@ParameterizedTest
274+
@ValueSource(strings = { "claude-opus-4-20250514", "claude-sonnet-4-20250514", "claude-3-7-sonnet-latest" })
275+
void streamingParameterLessTool(String modelName) {
276+
277+
ChatClient chatClient = ChatClient.builder(this.chatModel).build();
278+
279+
Flux<ChatResponse> responses = chatClient.prompt()
280+
.options(ToolCallingChatOptions.builder().model(modelName).build())
281+
.tools(new ParameterLessTools())
282+
.user("Get current weather in Amsterdam")
283+
.stream()
284+
.chatResponse();
285+
286+
String content = responses.collectList()
287+
.block()
288+
.stream()
289+
.filter(cr -> cr.getResult() != null)
290+
.map(cr -> cr.getResult().getOutput().getText())
291+
.collect(Collectors.joining());
292+
293+
assertThat(content).contains("20 degrees");
294+
}
295+
296+
public static class ParameterLessTools {
297+
298+
@Tool(description = "Get the current weather forecast in Amsterdam")
299+
String getCurrentDateTime() {
300+
return "Weather is hot and sunny with a temperature of 20 degrees";
301+
}
302+
303+
}
304+
265305
@Autowired
266306
ChatModel chatModel;
267307

models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistralai/MistralAiChatOptions.java

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -171,16 +171,17 @@ public static MistralAiChatOptions fromOptions(MistralAiChatOptions fromOptions)
171171
.temperature(fromOptions.getTemperature())
172172
.topP(fromOptions.getTopP())
173173
.responseFormat(fromOptions.getResponseFormat())
174-
.stop(fromOptions.getStop())
174+
.stop(fromOptions.getStop() != null ? new ArrayList<>(fromOptions.getStop()) : null)
175175
.frequencyPenalty(fromOptions.getFrequencyPenalty())
176176
.presencePenalty(fromOptions.getPresencePenalty())
177177
.n(fromOptions.getN())
178-
.tools(fromOptions.getTools())
178+
.tools(fromOptions.getTools() != null ? new ArrayList<>(fromOptions.getTools()) : null)
179179
.toolChoice(fromOptions.getToolChoice())
180-
.toolCallbacks(fromOptions.getToolCallbacks())
181-
.toolNames(fromOptions.getToolNames())
180+
.toolCallbacks(
181+
fromOptions.getToolCallbacks() != null ? new ArrayList<>(fromOptions.getToolCallbacks()) : null)
182+
.toolNames(fromOptions.getToolNames() != null ? new HashSet<>(fromOptions.getToolNames()) : null)
182183
.internalToolExecutionEnabled(fromOptions.getInternalToolExecutionEnabled())
183-
.toolContext(fromOptions.getToolContext())
184+
.toolContext(fromOptions.getToolContext() != null ? new HashMap<>(fromOptions.getToolContext()) : null)
184185
.build();
185186
}
186187

@@ -366,6 +367,7 @@ public void setToolContext(Map<String, Object> toolContext) {
366367
}
367368

368369
@Override
370+
@SuppressWarnings("unchecked")
369371
public MistralAiChatOptions copy() {
370372
return fromOptions(this);
371373
}

models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistralai/api/MistralAiApi.java

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -754,12 +754,16 @@ public enum ToolChoice {
754754
/**
755755
* An object specifying the format that the model must output.
756756
*
757-
* @param type Must be one of 'text' or 'json_object'.
758-
* @param jsonSchema A specific JSON schema to match, if 'type' is 'json_object'.
757+
* @param type Must be one of 'text', 'json_object' or 'json_schema'.
758+
* @param jsonSchema A specific JSON schema to match, if 'type' is 'json_schema'.
759759
*/
760760
@JsonInclude(Include.NON_NULL)
761761
public record ResponseFormat(@JsonProperty("type") String type,
762762
@JsonProperty("json_schema") Map<String, Object> jsonSchema) {
763+
764+
public ResponseFormat(String type) {
765+
this(type, null);
766+
}
763767
}
764768

765769
}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
/*
2+
* Copyright 2025-2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.ai.mistralai;
18+
19+
import java.util.List;
20+
import java.util.Map;
21+
22+
import static org.assertj.core.api.Assertions.assertThat;
23+
24+
import org.junit.jupiter.api.Test;
25+
import org.springframework.ai.mistralai.api.MistralAiApi.ChatCompletionRequest.ResponseFormat;
26+
27+
import org.springframework.ai.mistralai.api.MistralAiApi;
28+
29+
/**
30+
* Tests for {@link MistralAiChatOptions}.
31+
*
32+
* @author Alexandros Pappas
33+
*/
34+
class MistralAiChatOptionsTests {
35+
36+
@Test
37+
void testBuilderWithAllFields() {
38+
MistralAiChatOptions options = MistralAiChatOptions.builder()
39+
.model("test-model")
40+
.temperature(0.7)
41+
.topP(0.9)
42+
.maxTokens(100)
43+
.safePrompt(true)
44+
.randomSeed(123)
45+
.stop(List.of("stop1", "stop2"))
46+
.responseFormat(new ResponseFormat("json_object"))
47+
.toolChoice(MistralAiApi.ChatCompletionRequest.ToolChoice.AUTO)
48+
.internalToolExecutionEnabled(true)
49+
.toolContext(Map.of("key1", "value1"))
50+
.build();
51+
52+
assertThat(options)
53+
.extracting("model", "temperature", "topP", "maxTokens", "safePrompt", "randomSeed", "stop",
54+
"responseFormat", "toolChoice", "internalToolExecutionEnabled", "toolContext")
55+
.containsExactly("test-model", 0.7, 0.9, 100, true, 123, List.of("stop1", "stop2"),
56+
new ResponseFormat("json_object"), MistralAiApi.ChatCompletionRequest.ToolChoice.AUTO, true,
57+
Map.of("key1", "value1"));
58+
}
59+
60+
@Test
61+
void testBuilderWithEnum() {
62+
MistralAiChatOptions optionsWithEnum = MistralAiChatOptions.builder()
63+
.model(MistralAiApi.ChatModel.MINISTRAL_8B_LATEST)
64+
.build();
65+
assertThat(optionsWithEnum.getModel()).isEqualTo(MistralAiApi.ChatModel.MINISTRAL_8B_LATEST.getValue());
66+
}
67+
68+
@Test
69+
void testCopy() {
70+
MistralAiChatOptions options = MistralAiChatOptions.builder()
71+
.model("test-model")
72+
.temperature(0.7)
73+
.topP(0.9)
74+
.maxTokens(100)
75+
.safePrompt(true)
76+
.randomSeed(123)
77+
.stop(List.of("stop1", "stop2"))
78+
.responseFormat(new ResponseFormat("json_object"))
79+
.toolChoice(MistralAiApi.ChatCompletionRequest.ToolChoice.AUTO)
80+
.internalToolExecutionEnabled(true)
81+
.toolContext(Map.of("key1", "value1"))
82+
.build();
83+
84+
MistralAiChatOptions copiedOptions = options.copy();
85+
assertThat(copiedOptions).isNotSameAs(options).isEqualTo(options);
86+
// Ensure deep copy
87+
assertThat(copiedOptions.getStop()).isNotSameAs(options.getStop());
88+
assertThat(copiedOptions.getToolContext()).isNotSameAs(options.getToolContext());
89+
}
90+
91+
@Test
92+
void testSetters() {
93+
ResponseFormat responseFormat = new ResponseFormat("json_object");
94+
MistralAiChatOptions options = new MistralAiChatOptions();
95+
options.setModel("test-model");
96+
options.setTemperature(0.7);
97+
options.setTopP(0.9);
98+
options.setMaxTokens(100);
99+
options.setSafePrompt(true);
100+
options.setRandomSeed(123);
101+
options.setResponseFormat(responseFormat);
102+
options.setStopSequences(List.of("stop1", "stop2"));
103+
104+
assertThat(options.getModel()).isEqualTo("test-model");
105+
assertThat(options.getTemperature()).isEqualTo(0.7);
106+
assertThat(options.getTopP()).isEqualTo(0.9);
107+
assertThat(options.getMaxTokens()).isEqualTo(100);
108+
assertThat(options.getSafePrompt()).isEqualTo(true);
109+
assertThat(options.getRandomSeed()).isEqualTo(123);
110+
assertThat(options.getStopSequences()).isEqualTo(List.of("stop1", "stop2"));
111+
assertThat(options.getResponseFormat()).isEqualTo(responseFormat);
112+
}
113+
114+
@Test
115+
void testDefaultValues() {
116+
MistralAiChatOptions options = new MistralAiChatOptions();
117+
assertThat(options.getModel()).isNull();
118+
assertThat(options.getTemperature()).isNull();
119+
assertThat(options.getTopP()).isNull();
120+
assertThat(options.getMaxTokens()).isNull();
121+
assertThat(options.getSafePrompt()).isNull();
122+
assertThat(options.getRandomSeed()).isNull();
123+
assertThat(options.getStopSequences()).isNull();
124+
assertThat(options.getResponseFormat()).isNull();
125+
}
126+
127+
}

0 commit comments

Comments
 (0)