Add payment transaction status Function Calling ITs - OpenAI and Vertex AI Gemeini

This commit is contained in:
Christian Tzolov
2024-05-27 18:11:37 +02:00
parent 5226d0b9b3
commit a64dd06c48
5 changed files with 460 additions and 5 deletions

View File

@@ -0,0 +1,220 @@
/*
* Copyright 2024-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;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import reactor.core.publisher.Flux;
import org.springframework.ai.chat.client.AdvisedRequest;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.RequestResponseAdvisor;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.converter.BeanOutputConverter;
import org.springframework.ai.model.function.FunctionCallbackContext;
import org.springframework.ai.openai.OpenAiChatModel;
import org.springframework.ai.openai.OpenAiChatOptions;
import org.springframework.ai.openai.api.OpenAiApi;
import org.springframework.ai.openai.api.OpenAiApi.ChatModel;
import org.springframework.ai.retry.RetryUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Description;
import org.springframework.core.ParameterizedTypeReference;
import static org.assertj.core.api.Assertions.assertThat;
/**
* @author Christian Tzolov
*/
@SpringBootTest
@EnabledIfEnvironmentVariable(named = "VERTEX_AI_GEMINI_PROJECT_ID", matches = ".*")
@EnabledIfEnvironmentVariable(named = "VERTEX_AI_GEMINI_LOCATION", matches = ".*")
public class OpenAiPaymentTransactionIT {
private final static Logger logger = LoggerFactory.getLogger(OpenAiPaymentTransactionIT.class);
@Autowired
ChatClient chatClient;
record TransactionStatusResponse(String id, String status) {
}
private static class LoggingAdvisor implements RequestResponseAdvisor {
private final Logger logger = LoggerFactory.getLogger(LoggingAdvisor.class);
@Override
public AdvisedRequest adviseRequest(AdvisedRequest request, Map<String, Object> context) {
logger.info("System text: \n" + request.systemText());
logger.info("System params: " + request.systemParams());
logger.info("User text: \n" + request.userText());
logger.info("User params:" + request.userParams());
logger.info("Function names: " + request.functionNames());
logger.info("Options: " + request.chatOptions().toString());
return request;
}
@Override
public ChatResponse adviseResponse(ChatResponse response, Map<String, Object> context) {
logger.info("Response: " + response);
return response;
}
}
@ParameterizedTest(name = "{0} : {displayName} ")
@ValueSource(strings = { "paymentStatus", "paymentStatuses" })
public void transactionPaymentStatuses(String functionName) {
List<TransactionStatusResponse> content = this.chatClient.prompt()
.advisors(new LoggingAdvisor())
.functions(functionName)
.user("""
What is the status of my payment transactions 001, 002 and 003?
""")
.call()
.entity(new ParameterizedTypeReference<List<TransactionStatusResponse>>() {
});
logger.info("" + content);
assertThat(content.get(0).id()).isEqualTo("001");
assertThat(content.get(0).status()).isEqualTo("pending");
assertThat(content.get(1).id()).isEqualTo("002");
assertThat(content.get(1).status()).isEqualTo("approved");
assertThat(content.get(2).id()).isEqualTo("003");
assertThat(content.get(2).status()).isEqualTo("rejected");
}
@ParameterizedTest(name = "{0} : {displayName} ")
@ValueSource(strings = { "paymentStatus", "paymentStatuses" })
public void streamingPaymentStatuses(String functionName) {
var converter = new BeanOutputConverter<>(new ParameterizedTypeReference<List<TransactionStatusResponse>>() {
});
Flux<String> flux = this.chatClient.prompt()
.advisors(new LoggingAdvisor())
.functions(functionName)
.user(u -> u.text("""
What is the status of my payment transactions 001, 002 and 003?
{format}
""").param("format", converter.getFormat()))
.stream()
.content();
String content = flux.collectList().block().stream().collect(Collectors.joining());
List<TransactionStatusResponse> structure = converter.convert(content);
logger.info("" + content);
assertThat(structure.get(0).id()).isEqualTo("001");
assertThat(structure.get(0).status()).isEqualTo("pending");
assertThat(structure.get(1).id()).isEqualTo("002");
assertThat(structure.get(1).status()).isEqualTo("approved");
assertThat(structure.get(2).id()).isEqualTo("003");
assertThat(structure.get(2).status()).isEqualTo("rejected");
}
record Transaction(String id) {
}
record Status(String name) {
}
record Transactions(List<Transaction> transactions) {
}
record Statuses(List<Status> statuses) {
}
private static final Map<Transaction, Status> DATASET = Map.of(new Transaction("001"), new Status("pending"),
new Transaction("002"), new Status("approved"), new Transaction("003"), new Status("rejected"));
@SpringBootConfiguration
public static class TestConfiguration {
@Bean
@Description("Get the status of a single payment transaction")
public Function<Transaction, Status> paymentStatus() {
return transaction -> {
logger.info("Single transaction: " + transaction);
return DATASET.get(transaction);
};
}
@Bean
@Description("Get the list statuses of a list of payment transactions")
public Function<Transactions, Statuses> paymentStatuses() {
return transactions -> {
logger.info("List of transactions: " + transactions);
return new Statuses(transactions.transactions().stream().map(t -> DATASET.get(t)).toList());
};
}
@Bean
public ChatClient chatClient(OpenAiChatModel chatModel) {
return ChatClient.builder(chatModel).build();
}
@Bean
public OpenAiApi chatCompletionApi() {
return new OpenAiApi(System.getenv("OPENAI_API_KEY"));
}
@Bean
public OpenAiChatModel openAiClient(OpenAiApi openAiApi, FunctionCallbackContext functionCallbackContext) {
return new OpenAiChatModel(openAiApi,
OpenAiChatOptions.builder()
.withModel(ChatModel.GPT_4_0_TURBO.getModelName())
.withTemperature(0.1f)
.build(),
functionCallbackContext, RetryUtils.DEFAULT_RETRY_TEMPLATE);
}
/**
* Because of the OPEN_API_SCHEMA type, the FunctionCallbackContext instance must
* different from the other JSON schema types.
*/
@Bean
public FunctionCallbackContext springAiFunctionManager(ApplicationContext context) {
FunctionCallbackContext manager = new FunctionCallbackContext();
manager.setApplicationContext(context);
return manager;
}
}
}

View File

@@ -336,6 +336,19 @@ public class VertexAiGeminiChatOptions implements FunctionCallingOptions, ChatOp
return true;
}
@Override
public String toString() {
return "VertexAiGeminiChatOptions [stopSequences=" + stopSequences + ", temperature=" + temperature + ", topP="
+ topP + ", topK=" + topK + ", candidateCount=" + candidateCount + ", maxOutputTokens="
+ maxOutputTokens + ", model=" + model + ", functionCallbacks=" + functionCallbacks + ", functions="
+ functions + ", getClass()=" + getClass() + ", getStopSequences()=" + getStopSequences()
+ ", getTemperature()=" + getTemperature() + ", getTopP()=" + getTopP() + ", getTopK()=" + getTopK()
+ ", getCandidateCount()=" + getCandidateCount() + ", getMaxOutputTokens()=" + getMaxOutputTokens()
+ ", getModel()=" + getModel() + ", getFunctionCallbacks()=" + getFunctionCallbacks()
+ ", getFunctions()=" + getFunctions() + ", hashCode()=" + hashCode() + ", toString()="
+ super.toString() + "]";
}
public static VertexAiGeminiChatOptions fromOptions(VertexAiGeminiChatOptions fromOptions) {
VertexAiGeminiChatOptions options = new VertexAiGeminiChatOptions();
options.setStopSequences(fromOptions.getStopSequences());

View File

@@ -55,7 +55,7 @@ public class VertexAiGeminiChatModelFunctionCallingIT {
private final Logger logger = LoggerFactory.getLogger(getClass());
@Autowired
private VertexAiGeminiChatModel vertexGeminiClient;
private VertexAiGeminiChatModel chatModel;
@AfterEach
public void afterEach() {
@@ -107,7 +107,7 @@ public class VertexAiGeminiChatModelFunctionCallingIT {
.build()))
.build();
ChatResponse response = vertexGeminiClient.call(new Prompt(messages, promptOptions));
ChatResponse response = chatModel.call(new Prompt(messages, promptOptions));
logger.info("Response: {}", response);
@@ -142,13 +142,13 @@ public class VertexAiGeminiChatModelFunctionCallingIT {
.build()))
.build();
ChatResponse response = vertexGeminiClient.call(new Prompt(messages, promptOptions));
ChatResponse response = chatModel.call(new Prompt(messages, promptOptions));
logger.info("Response: {}", response);
assertThat(response.getResult().getOutput().getContent()).containsAnyOf("15.0", "15");
ChatResponse response2 = vertexGeminiClient
ChatResponse response2 = chatModel
.call(new Prompt("What is the payment status for transaction 696?", promptOptions));
logger.info("Response: {}", response2);
@@ -176,7 +176,7 @@ public class VertexAiGeminiChatModelFunctionCallingIT {
.build()))
.build();
Flux<ChatResponse> response = vertexGeminiClient.stream(new Prompt(messages, promptOptions));
Flux<ChatResponse> response = chatModel.stream(new Prompt(messages, promptOptions));
String responseString = response.collectList()
.block()

View File

@@ -0,0 +1,213 @@
/*
* Copyright 2024-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.vertexai.gemini.function;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
import com.google.cloud.vertexai.Transport;
import com.google.cloud.vertexai.VertexAI;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.RepeatedTest;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import reactor.core.publisher.Flux;
import org.springframework.ai.chat.client.AdvisedRequest;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.RequestResponseAdvisor;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.model.function.FunctionCallbackContext;
import org.springframework.ai.model.function.FunctionCallbackWrapper.Builder.SchemaType;
import org.springframework.ai.vertexai.gemini.VertexAiGeminiChatModel;
import org.springframework.ai.vertexai.gemini.VertexAiGeminiChatOptions;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Description;
/**
* @author Christian Tzolov
*/
@Disabled("Vertex AI Gemini function calling is very unstable.")
@SpringBootTest
@EnabledIfEnvironmentVariable(named = "VERTEX_AI_GEMINI_PROJECT_ID", matches = ".*")
@EnabledIfEnvironmentVariable(named = "VERTEX_AI_GEMINI_LOCATION", matches = ".*")
public class VertexAiGeminiPaymentTransactionIT {
private final static Logger logger = LoggerFactory.getLogger(VertexAiGeminiPaymentTransactionIT.class);
@Autowired
ChatClient chatClient;
record TransactionStatusResponse(String id, String status) {
}
private static class LoggingAdvisor implements RequestResponseAdvisor {
private final Logger logger = LoggerFactory.getLogger(LoggingAdvisor.class);
@Override
public AdvisedRequest adviseRequest(AdvisedRequest request, Map<String, Object> context) {
logger.info("System text: \n" + request.systemText());
logger.info("System params: " + request.systemParams());
logger.info("User text: \n" + request.userText());
logger.info("User params:" + request.userParams());
logger.info("Function names: " + request.functionNames());
logger.info("Options: " + request.chatOptions().toString());
return request;
}
@Override
public ChatResponse adviseResponse(ChatResponse response, Map<String, Object> context) {
logger.info("Response: " + response);
return response;
}
}
@Test
public void paymentStatuses() {
String content = this.chatClient.prompt().advisors(new LoggingAdvisor()).functions("paymentStatus").user("""
What is the status of my payment transactions 001, 002 and 003?
To answer this question invoke the 'paymentStatus' function per transaction.
""").call().content();
logger.info("" + content);
}
@RepeatedTest(10)
public void streamingPaymentStatuses() {
Flux<String> streamContent = this.chatClient.prompt()
.advisors(new LoggingAdvisor())
.functions("paymentStatus")
// .functions("paymentStatuses")
.user("""
What is the status of my payment transactions 001, 002 and 003?
To answer this question invoke the paymentStatus function per transaction.
Return the transaction id and the transaction status for each transaction.
""")
.stream()
.content();
String content = streamContent.collectList().block().stream().collect(Collectors.joining());
logger.info(content);
// Quota rate
try {
Thread.sleep(20000);
}
catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
record Transaction(String id) {
}
record Status(String name) {
}
record Transactions(List<Transaction> transactions) {
}
record Statuses(List<Status> statuses) {
}
private static final Map<Transaction, Status> DATASET = Map.of(new Transaction("001"), new Status("pending"),
new Transaction("002"), new Status("approved"), new Transaction("003"), new Status("rejected"));
@SpringBootConfiguration
public static class TestConfiguration {
@Bean
@Description("Get the status of a single payment transaction")
public Function<Transaction, Status> paymentStatus() {
return transaction -> {
logger.info("Single Transaction: " + transaction);
return DATASET.get(transaction);
};
}
@Bean
@Description("Get the list statuses of a list of payment transactions")
public Function<Transactions, Statuses> paymentStatuses() {
return transactions -> {
logger.info("Transactions: " + transactions);
return new Statuses(transactions.transactions().stream().map(t -> DATASET.get(t)).toList());
};
}
@Bean
public ChatClient chatClient(VertexAiGeminiChatModel chatModel) {
return ChatClient.builder(chatModel).build();
}
@Bean
public VertexAI vertexAiApi() {
String projectId = System.getenv("VERTEX_AI_GEMINI_PROJECT_ID");
String location = System.getenv("VERTEX_AI_GEMINI_LOCATION");
return new VertexAI.Builder().setLocation(location)
.setProjectId(projectId)
.setTransport(Transport.REST)
// .setTransport(Transport.GRPC)
.build();
}
@Bean
public VertexAiGeminiChatModel vertexAiChatModel(VertexAI vertexAi, ApplicationContext context) {
FunctionCallbackContext functionCallbackContext = springAiFunctionManager(context);
return new VertexAiGeminiChatModel(vertexAi,
VertexAiGeminiChatOptions.builder()
.withModel(VertexAiGeminiChatModel.ChatModel.GEMINI_PRO_1_5_FLASH)
// .withModel(VertexAiGeminiChatModel.ChatModel.GEMINI_PRO_1_5_PRO)
.withTemperature(0.1f)
// .withResponseMimeType(ResponseMimeType.JSON)
.build(),
functionCallbackContext);
}
/**
* Because of the OPEN_API_SCHEMA type, the FunctionCallbackContext instance must
* different from the other JSON schema types.
*/
private FunctionCallbackContext springAiFunctionManager(ApplicationContext context) {
FunctionCallbackContext manager = new FunctionCallbackContext();
manager.setSchemaType(SchemaType.OPEN_API_SCHEMA);
manager.setApplicationContext(context);
return manager;
}
}
}

View File

@@ -29,6 +29,8 @@ import com.github.victools.jsonschema.generator.SchemaGenerator;
import com.github.victools.jsonschema.generator.SchemaGeneratorConfig;
import com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder;
import com.github.victools.jsonschema.module.jackson.JacksonModule;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.lang.NonNull;
@@ -52,6 +54,8 @@ import static com.github.victools.jsonschema.generator.SchemaVersion.DRAFT_2020_
*/
public class BeanOutputConverter<T> implements StructuredOutputConverter<T> {
private final Logger logger = LoggerFactory.getLogger(BeanOutputConverter.class);
/** Holds the generated JSON schema for the target type. */
private String jsonSchema;
@@ -147,6 +151,7 @@ public class BeanOutputConverter<T> implements StructuredOutputConverter<T> {
this.jsonSchema = objectWriter.writeValueAsString(jsonNode);
}
catch (JsonProcessingException e) {
logger.error("Could not pretty print json schema for jsonNode: " + jsonNode);
throw new RuntimeException("Could not pretty print json schema for " + this.typeRef, e);
}
}
@@ -159,9 +164,13 @@ public class BeanOutputConverter<T> implements StructuredOutputConverter<T> {
*/
public T convert(@NonNull String text) {
try {
if (text.startsWith("```json") && text.endsWith("```")) {
text = text.substring(7, text.length() - 3);
}
return (T) this.objectMapper.readValue(text, this.typeRef);
}
catch (JsonProcessingException e) {
logger.error("Could not parse the given text to the desired target type:" + text + " into " + this.typeRef);
throw new RuntimeException(e);
}
}