Ensure that ChatClient copies the input chatoptions

- introduce copy() method to the ChatOptions.
 - make sure that the DefaultChatClientRequestSpec takes a copy of the input chat options to prevent multation.
 - add a OpenAiChatClientMultipleFunctionCallsIT to reproduce the problem and verify the solution.

 Resolves #1064
This commit is contained in:
Christian Tzolov
2024-07-17 17:55:19 +02:00
parent 75db25b13d
commit 018bb2c600
24 changed files with 249 additions and 5 deletions

View File

@@ -229,6 +229,11 @@ public class AnthropicChatOptions implements ChatOptions, FunctionCallingOptions
this.functions = functions;
}
@Override
public AnthropicChatOptions copy() {
return fromOptions(this);
}
public static AnthropicChatOptions fromOptions(AnthropicChatOptions fromOptions) {
return builder().withModel(fromOptions.getModel())
.withMaxTokens(fromOptions.getMaxTokens())

View File

@@ -378,6 +378,11 @@ public class AzureOpenAiChatOptions implements FunctionCallingOptions, ChatOptio
this.responseFormat = responseFormat;
}
@Override
public AzureOpenAiChatOptions copy() {
return fromOptions(this);
}
public static AzureOpenAiChatOptions fromOptions(AzureOpenAiChatOptions fromOptions) {
return builder().withDeploymentName(fromOptions.getDeploymentName())
.withFrequencyPenalty(

View File

@@ -164,6 +164,11 @@ public class AnthropicChatOptions implements ChatOptions {
this.anthropicVersion = anthropicVersion;
}
@Override
public AnthropicChatOptions copy() {
return fromOptions(this);
}
public static AnthropicChatOptions fromOptions(AnthropicChatOptions fromOptions) {
return builder().withTemperature(fromOptions.getTemperature())
.withMaxTokensToSample(fromOptions.getMaxTokensToSample())

View File

@@ -163,6 +163,11 @@ public class Anthropic3ChatOptions implements ChatOptions {
this.anthropicVersion = anthropicVersion;
}
@Override
public Anthropic3ChatOptions copy() {
return fromOptions(this);
}
public static Anthropic3ChatOptions fromOptions(Anthropic3ChatOptions fromOptions) {
return builder().withTemperature(fromOptions.getTemperature())
.withMaxTokens(fromOptions.getMaxTokens())

View File

@@ -213,6 +213,11 @@ public class BedrockCohereChatOptions implements ChatOptions {
this.truncate = truncate;
}
@Override
public BedrockCohereChatOptions copy() {
return fromOptions(this);
}
public static BedrockCohereChatOptions fromOptions(BedrockCohereChatOptions fromOptions) {
return builder().withTemperature(fromOptions.getTemperature())
.withTopP(fromOptions.getTopP())

View File

@@ -413,6 +413,11 @@ public class BedrockAi21Jurassic2ChatOptions implements ChatOptions {
}
}
@Override
public BedrockAi21Jurassic2ChatOptions copy() {
return fromOptions(this);
}
public static BedrockAi21Jurassic2ChatOptions fromOptions(BedrockAi21Jurassic2ChatOptions fromOptions) {
return builder().withPrompt(fromOptions.getPrompt())
.withNumResults(fromOptions.getNumResults())

View File

@@ -109,6 +109,11 @@ public class BedrockLlamaChatOptions implements ChatOptions {
throw new UnsupportedOperationException("Unsupported option: 'TopK'");
}
@Override
public BedrockLlamaChatOptions copy() {
return fromOptions(this);
}
public static BedrockLlamaChatOptions fromOptions(BedrockLlamaChatOptions fromOptions) {
return builder().withTemperature(fromOptions.getTemperature())
.withTopP(fromOptions.getTopP())

View File

@@ -128,6 +128,11 @@ public class BedrockTitanChatOptions implements ChatOptions {
throw new UnsupportedOperationException("Bedrock Titan Chat does not support the 'TopK' option.'");
}
@Override
public BedrockTitanChatOptions copy() {
return fromOptions(this);
}
public static BedrockTitanChatOptions fromOptions(BedrockTitanChatOptions fromOptions) {
return builder().withTemperature(fromOptions.getTemperature())
.withTopP(fromOptions.getTopP())

View File

@@ -467,6 +467,11 @@ public class MiniMaxChatOptions implements FunctionCallingOptions, ChatOptions {
return true;
}
@Override
public MiniMaxChatOptions copy() {
return fromOptions(this);
}
public static MiniMaxChatOptions fromOptions(MiniMaxChatOptions fromOptions) {
return builder().withModel(fromOptions.getModel())
.withFrequencyPenalty(fromOptions.getFrequencyPenalty())

View File

@@ -315,6 +315,11 @@ public class MistralAiChatOptions implements FunctionCallingOptions, ChatOptions
this.functions = functions;
}
@Override
public MistralAiChatOptions copy() {
return fromOptions(this);
}
public static MistralAiChatOptions fromOptions(MistralAiChatOptions fromOptions) {
return builder().withModel(fromOptions.getModel())
.withMaxTokens(fromOptions.getMaxTokens())

View File

@@ -303,6 +303,24 @@ public class MoonshotChatOptions implements ChatOptions {
this.user = user;
}
@Override
public MoonshotChatOptions copy() {
return builder().withModel(this.model)
.withMaxTokens(this.maxTokens)
.withTemperature(this.temperature)
.withTopP(this.topP)
.withN(this.n)
.withPresencePenalty(this.presencePenalty)
.withFrequencyPenalty(this.frequencyPenalty)
.withStop(this.stop)
.withUser(this.user)
.withTools(this.tools)
.withToolChoice(this.toolChoice)
.withFunctionCallbacks(this.functionCallbacks)
.withFunctions(this.functions)
.build();
}
@Override
public int hashCode() {
final int prime = 31;

View File

@@ -714,6 +714,11 @@ public class OllamaOptions implements ChatOptions, EmbeddingOptions {
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
}
@Override
public OllamaOptions copy() {
return fromOptions(this);
}
public static OllamaOptions fromOptions(OllamaOptions fromOptions) {
return new OllamaOptions()
.withModel(fromOptions.getModel())

View File

@@ -609,6 +609,11 @@ public class OpenAiChatOptions implements FunctionCallingOptions, ChatOptions {
throw new UnsupportedOperationException("Unimplemented method 'setTopK'");
}
@Override
public OpenAiChatOptions copy() {
return OpenAiChatOptions.fromOptions(this);
}
public static OpenAiChatOptions fromOptions(OpenAiChatOptions fromOptions) {
return OpenAiChatOptions.builder()
.withModel(fromOptions.getModel())

View File

@@ -0,0 +1,126 @@
/*
* Copyright 2023 - 2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.openai.chat.client;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.List;
import java.util.stream.Collectors;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.openai.OpenAiTestConfiguration;
import org.springframework.ai.openai.api.tool.MockWeatherService;
import org.springframework.ai.openai.testutils.AbstractIT;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.core.io.Resource;
import org.springframework.test.context.ActiveProfiles;
import reactor.core.publisher.Flux;
@SpringBootTest(classes = OpenAiTestConfiguration.class)
@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".+")
@ActiveProfiles("logging-test")
class OpenAiChatClientMultipleFunctionCallsIT extends AbstractIT {
private static final Logger logger = LoggerFactory.getLogger(OpenAiChatClientMultipleFunctionCallsIT.class);
@Value("classpath:/prompts/system-message.st")
private Resource systemTextResource;
record ActorsFilms(String actor, List<String> movies) {
}
@Test
void turnFunctionsOnAndOffTest() {
var chatClientBuilder = ChatClient.builder(chatModel);
// @formatter:off
String response = chatClientBuilder.build().prompt()
.user(u -> u.text("What's the weather like in San Francisco, Tokyo, and Paris?"))
.call()
.content();
// @formatter:on
logger.info("Response: {}", response);
assertThat(response).doesNotContain("30", "10", "15");
// @formatter:off
response = chatClientBuilder.build().prompt()
.user(u -> u.text("What's the weather like in San Francisco, Tokyo, and Paris?"))
.function("getCurrentWeather", "Get the weather in location", new MockWeatherService())
.call()
.content();
// @formatter:on
logger.info("Response: {}", response);
assertThat(response).contains("30", "10", "15");
// @formatter:off
response = chatClientBuilder.build().prompt()
.user(u -> u.text("What's the weather like in San Francisco, Tokyo, and Paris?"))
.call()
.content();
// @formatter:on
logger.info("Response: {}", response);
assertThat(response).doesNotContain("30", "10", "15");
}
@Test
void defaultFunctionCallTest() {
// @formatter:off
String response = ChatClient.builder(chatModel)
.defaultFunction("getCurrentWeather", "Get the weather in location", new MockWeatherService())
.defaultUser(u -> u.text("What's the weather like in San Francisco, Tokyo, and Paris?"))
.build()
.prompt().call().content();
// @formatter:on
logger.info("Response: {}", response);
assertThat(response).contains("30", "10", "15");
}
@Test
void streamFunctionCallTest() {
// @formatter:off
Flux<String> response = ChatClient.create(chatModel).prompt()
.user("What's the weather like in San Francisco, Tokyo, and Paris?")
.function("getCurrentWeather", "Get the weather in location", new MockWeatherService())
.stream()
.content();
// @formatter:on
String content = response.collectList().block().stream().collect(Collectors.joining());
logger.info("Response: {}", content);
assertThat(content).contains("30", "10", "15");
}
}

View File

@@ -290,6 +290,11 @@ public class QianFanChatOptions implements ChatOptions {
return true;
}
@Override
public QianFanChatOptions copy() {
return fromOptions(this);
}
public static QianFanChatOptions fromOptions(QianFanChatOptions fromOptions) {
return QianFanChatOptions.builder()
.withModel(fromOptions.getModel())

View File

@@ -349,6 +349,11 @@ public class VertexAiGeminiChatOptions implements FunctionCallingOptions, ChatOp
+ super.toString() + "]";
}
@Override
public VertexAiGeminiChatOptions copy() {
return fromOptions(this);
}
public static VertexAiGeminiChatOptions fromOptions(VertexAiGeminiChatOptions fromOptions) {
VertexAiGeminiChatOptions options = new VertexAiGeminiChatOptions();
options.setStopSequences(fromOptions.getStopSequences());

View File

@@ -127,6 +127,11 @@ public class VertexAiPaLm2ChatOptions implements ChatOptions {
this.topK = topK;
}
@Override
public VertexAiPaLm2ChatOptions copy() {
return fromOptions(this);
}
public static VertexAiPaLm2ChatOptions fromOptions(VertexAiPaLm2ChatOptions fromOptions) {
return VertexAiPaLm2ChatOptions.builder()
.withTemperature(fromOptions.getTemperature())

View File

@@ -324,6 +324,11 @@ public class WatsonxAiChatOptions implements ChatOptions {
return input != null ? input.replaceAll("([a-z])([A-Z]+)", "$1_$2").toLowerCase() : null;
}
@Override
public WatsonxAiChatOptions copy() {
return fromOptions(this);
}
public static WatsonxAiChatOptions fromOptions(WatsonxAiChatOptions fromOptions) {
return WatsonxAiChatOptions.builder()
.withTemperature(fromOptions.getTemperature())

View File

@@ -412,6 +412,11 @@ public class ZhiPuAiChatOptions implements FunctionCallingOptions, ChatOptions {
throw new UnsupportedOperationException("Unimplemented method 'setTopK'");
}
@Override
public ZhiPuAiChatOptions copy() {
return fromOptions(this);
}
public static ZhiPuAiChatOptions fromOptions(ZhiPuAiChatOptions fromOptions) {
return ZhiPuAiChatOptions.builder()
.withModel(fromOptions.getModel())

View File

@@ -17,16 +17,12 @@ package org.springframework.ai.chat.client;
import java.net.URL;
import java.nio.charset.Charset;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
import reactor.core.publisher.Flux;
import org.springframework.ai.chat.messages.Media;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.prompt.ChatOptions;
@@ -36,6 +32,8 @@ import org.springframework.core.ParameterizedTypeReference;
import org.springframework.core.io.Resource;
import org.springframework.util.MimeType;
import reactor.core.publisher.Flux;
/**
* Client to perform stateless requests to an AI Model, using a fluent API.
*

View File

@@ -533,7 +533,7 @@ public class DefaultChatClient implements ChatClient {
List<RequestResponseAdvisor> advisors, Map<String, Object> advisorParams) {
this.chatModel = chatModel;
this.chatOptions = chatOptions != null ? chatOptions : chatModel.getDefaultOptions();
this.chatOptions = chatOptions != null ? chatOptions.copy() : chatModel.getDefaultOptions().copy();
this.userText = userText;
this.userParams.putAll(userParams);

View File

@@ -28,4 +28,6 @@ public interface ChatOptions extends ModelOptions {
Integer getTopK();
ChatOptions copy();
}

View File

@@ -52,6 +52,11 @@ public class ChatOptionsBuilder {
this.topK = topK;
}
@Override
public ChatOptions copy() {
return builder().withTemperature(this.temperature).withTopP(this.topP).withTopK(this.topK).build();
}
}
private final ChatOptionsImpl options = new ChatOptionsImpl();

View File

@@ -139,6 +139,16 @@ public class FunctionCallingOptionsBuilder {
this.topK = topK;
}
@Override
public ChatOptions copy() {
return new FunctionCallingOptionsBuilder().withTemperature(this.temperature)
.withTopP(this.topP)
.withTopK(this.topK)
.withFunctions(this.functions)
.withFunctionCallbacks(this.functionCallbacks)
.build();
}
}
}