feat(vertex-ai): Refactor Gemini for new tool calling API
- Migrate from function calling to tool calling API - Add support for Gemini 2.0 models (flash, flash-lite) - Implement JSON schema to OpenAPI schema conversion - Add builder pattern for improved configuration - Deprecate legacy function calling constructors and methods - Update default model to GEMINI_2_0_FLASH - Add comprehensive test coverage for tool calling - Upgrade victools dependency to 4.37.0 - Update the Vertex Tool calling docs Part of the #2207 epic Signed-off-by: Christian Tzolov <christian.tzolov@broadcom.com>
This commit is contained in:
committed by
Mark Pollack
parent
4d692a542b
commit
710bb05f19
@@ -16,7 +16,8 @@
|
||||
-->
|
||||
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>org.springframework.ai</groupId>
|
||||
@@ -53,6 +54,17 @@
|
||||
|
||||
<dependencies>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.github.victools</groupId>
|
||||
<artifactId>jsonschema-generator</artifactId>
|
||||
<version>${victools.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.github.victools</groupId>
|
||||
<artifactId>jsonschema-module-jackson</artifactId>
|
||||
<version>${victools.version}</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.google.cloud</groupId>
|
||||
<artifactId>google-cloud-vertexai</artifactId>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2023-2024 the original author or authors.
|
||||
* Copyright 2023-2025 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.
|
||||
@@ -18,10 +18,8 @@ package org.springframework.ai.vertexai.gemini;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude.Include;
|
||||
@@ -47,7 +45,10 @@ import com.google.protobuf.util.JsonFormat;
|
||||
import io.micrometer.observation.Observation;
|
||||
import io.micrometer.observation.ObservationRegistry;
|
||||
import io.micrometer.observation.contextpropagation.ObservationThreadLocalAccessor;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import org.springframework.ai.chat.messages.AssistantMessage;
|
||||
import org.springframework.ai.chat.messages.Message;
|
||||
@@ -75,9 +76,15 @@ import org.springframework.ai.model.ModelOptionsUtils;
|
||||
import org.springframework.ai.model.function.FunctionCallback;
|
||||
import org.springframework.ai.model.function.FunctionCallbackResolver;
|
||||
import org.springframework.ai.model.function.FunctionCallingOptions;
|
||||
import org.springframework.ai.model.tool.LegacyToolCallingManager;
|
||||
import org.springframework.ai.model.tool.ToolCallingChatOptions;
|
||||
import org.springframework.ai.model.tool.ToolCallingManager;
|
||||
import org.springframework.ai.model.tool.ToolExecutionResult;
|
||||
import org.springframework.ai.retry.RetryUtils;
|
||||
import org.springframework.ai.tool.definition.ToolDefinition;
|
||||
import org.springframework.ai.vertexai.gemini.common.VertexAiGeminiConstants;
|
||||
import org.springframework.ai.vertexai.gemini.common.VertexAiGeminiSafetySetting;
|
||||
import org.springframework.ai.vertexai.gemini.schema.VertexToolCallingManager;
|
||||
import org.springframework.beans.factory.DisposableBean;
|
||||
import org.springframework.lang.NonNull;
|
||||
import org.springframework.retry.support.RetryTemplate;
|
||||
@@ -102,6 +109,10 @@ public class VertexAiGeminiChatModel extends AbstractToolCallSupport implements
|
||||
|
||||
private static final ChatModelObservationConvention DEFAULT_OBSERVATION_CONVENTION = new DefaultChatModelObservationConvention();
|
||||
|
||||
private static final ToolCallingManager DEFAULT_TOOL_CALLING_MANAGER = ToolCallingManager.builder().build();
|
||||
|
||||
private final Logger logger = LoggerFactory.getLogger(getClass());
|
||||
|
||||
private final VertexAI vertexAI;
|
||||
|
||||
private final VertexAiGeminiChatOptions defaultOptions;
|
||||
@@ -118,29 +129,54 @@ public class VertexAiGeminiChatModel extends AbstractToolCallSupport implements
|
||||
*/
|
||||
private final ObservationRegistry observationRegistry;
|
||||
|
||||
/**
|
||||
* Tool calling manager used to call tools.
|
||||
*/
|
||||
private final ToolCallingManager toolCallingManager;
|
||||
|
||||
/**
|
||||
* Conventions to use for generating observations.
|
||||
*/
|
||||
private ChatModelObservationConvention observationConvention = DEFAULT_OBSERVATION_CONVENTION;
|
||||
|
||||
/**
|
||||
* @deprecated Use {@link VertexAiGeminiChatModel.Builder}.
|
||||
*/
|
||||
@Deprecated
|
||||
public VertexAiGeminiChatModel(VertexAI vertexAI) {
|
||||
this(vertexAI, VertexAiGeminiChatOptions.builder().model(ChatModel.GEMINI_1_5_PRO).temperature(0.8).build());
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use {@link VertexAiGeminiChatModel.Builder}.
|
||||
*/
|
||||
@Deprecated
|
||||
public VertexAiGeminiChatModel(VertexAI vertexAI, VertexAiGeminiChatOptions options) {
|
||||
this(vertexAI, options, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use {@link VertexAiGeminiChatModel.Builder}.
|
||||
*/
|
||||
@Deprecated
|
||||
public VertexAiGeminiChatModel(VertexAI vertexAI, VertexAiGeminiChatOptions options,
|
||||
FunctionCallbackResolver functionCallbackResolver) {
|
||||
this(vertexAI, options, functionCallbackResolver, List.of());
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use {@link VertexAiGeminiChatModel.Builder}.
|
||||
*/
|
||||
@Deprecated
|
||||
public VertexAiGeminiChatModel(VertexAI vertexAI, VertexAiGeminiChatOptions options,
|
||||
FunctionCallbackResolver functionCallbackResolver, List<FunctionCallback> toolFunctionCallbacks) {
|
||||
this(vertexAI, options, functionCallbackResolver, toolFunctionCallbacks, RetryUtils.DEFAULT_RETRY_TEMPLATE);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use {@link VertexAiGeminiChatModel.Builder}.
|
||||
*/
|
||||
@Deprecated
|
||||
public VertexAiGeminiChatModel(VertexAI vertexAI, VertexAiGeminiChatOptions options,
|
||||
FunctionCallbackResolver functionCallbackResolver, List<FunctionCallback> toolFunctionCallbacks,
|
||||
RetryTemplate retryTemplate) {
|
||||
@@ -148,22 +184,49 @@ public class VertexAiGeminiChatModel extends AbstractToolCallSupport implements
|
||||
ObservationRegistry.NOOP);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use {@link VertexAiGeminiChatModel.Builder}.
|
||||
*/
|
||||
@Deprecated
|
||||
public VertexAiGeminiChatModel(VertexAI vertexAI, VertexAiGeminiChatOptions options,
|
||||
FunctionCallbackResolver functionCallbackResolver, List<FunctionCallback> toolFunctionCallbacks,
|
||||
RetryTemplate retryTemplate, ObservationRegistry observationRegistry) {
|
||||
|
||||
super(functionCallbackResolver, options, toolFunctionCallbacks);
|
||||
this(vertexAI, options,
|
||||
LegacyToolCallingManager.builder()
|
||||
.functionCallbackResolver(functionCallbackResolver)
|
||||
.functionCallbacks(toolFunctionCallbacks)
|
||||
.build(),
|
||||
retryTemplate, observationRegistry);
|
||||
logger.warn("This constructor is deprecated and will be removed in the next milestone. "
|
||||
+ "Please use the new constructor accepting ToolCallingManager instead.");
|
||||
|
||||
}
|
||||
|
||||
public VertexAiGeminiChatModel(VertexAI vertexAI, VertexAiGeminiChatOptions defaultOptions,
|
||||
ToolCallingManager toolCallingManager, RetryTemplate retryTemplate,
|
||||
ObservationRegistry observationRegistry) {
|
||||
|
||||
super(null, VertexAiGeminiChatOptions.builder().build(), List.of());
|
||||
|
||||
Assert.notNull(vertexAI, "VertexAI must not be null");
|
||||
Assert.notNull(options, "VertexAiGeminiChatOptions must not be null");
|
||||
Assert.notNull(options.getModel(), "VertexAiGeminiChatOptions.modelName must not be null");
|
||||
Assert.notNull(defaultOptions, "VertexAiGeminiChatOptions must not be null");
|
||||
Assert.notNull(defaultOptions.getModel(), "VertexAiGeminiChatOptions.modelName must not be null");
|
||||
Assert.notNull(retryTemplate, "RetryTemplate must not be null");
|
||||
Assert.notNull(toolCallingManager, "ToolCallingManager must not be null");
|
||||
|
||||
this.vertexAI = vertexAI;
|
||||
this.defaultOptions = options;
|
||||
this.generationConfig = toGenerationConfig(options);
|
||||
this.defaultOptions = defaultOptions;
|
||||
this.generationConfig = toGenerationConfig(defaultOptions);
|
||||
this.retryTemplate = retryTemplate;
|
||||
this.observationRegistry = observationRegistry;
|
||||
|
||||
if (toolCallingManager instanceof VertexToolCallingManager) {
|
||||
this.toolCallingManager = toolCallingManager;
|
||||
}
|
||||
else {
|
||||
this.toolCallingManager = new VertexToolCallingManager(toolCallingManager);
|
||||
}
|
||||
}
|
||||
|
||||
private static GeminiMessageType toGeminiMessageType(@NonNull MessageType type) {
|
||||
@@ -287,13 +350,16 @@ public class VertexAiGeminiChatModel extends AbstractToolCallSupport implements
|
||||
// https://cloud.google.com/vertex-ai/docs/generative-ai/model-reference/gemini
|
||||
@Override
|
||||
public ChatResponse call(Prompt prompt) {
|
||||
var requestPrompt = this.buildRequestPrompt(prompt);
|
||||
return this.internalCall(requestPrompt);
|
||||
}
|
||||
|
||||
VertexAiGeminiChatOptions vertexAiGeminiChatOptions = vertexAiGeminiChatOptions(prompt);
|
||||
private ChatResponse internalCall(Prompt prompt) {
|
||||
|
||||
ChatModelObservationContext observationContext = ChatModelObservationContext.builder()
|
||||
.prompt(prompt)
|
||||
.provider(VertexAiGeminiConstants.PROVIDER_NAME)
|
||||
.requestOptions(vertexAiGeminiChatOptions)
|
||||
.requestOptions(prompt.getOptions())
|
||||
.build();
|
||||
|
||||
ChatResponse response = ChatModelObservationDocumentation.CHAT_MODEL_OPERATION
|
||||
@@ -301,7 +367,7 @@ public class VertexAiGeminiChatModel extends AbstractToolCallSupport implements
|
||||
this.observationRegistry)
|
||||
.observe(() -> this.retryTemplate.execute(context -> {
|
||||
|
||||
var geminiRequest = createGeminiRequest(prompt, vertexAiGeminiChatOptions);
|
||||
var geminiRequest = createGeminiRequest(prompt);
|
||||
|
||||
GenerateContentResponse generateContentResponse = this.getContentResponse(geminiRequest);
|
||||
|
||||
@@ -318,26 +384,94 @@ public class VertexAiGeminiChatModel extends AbstractToolCallSupport implements
|
||||
return chatResponse;
|
||||
}));
|
||||
|
||||
if (!isProxyToolCalls(prompt, this.defaultOptions) && isToolCall(response, Set.of(FinishReason.STOP.name()))) {
|
||||
var toolCallConversation = handleToolCalls(prompt, response);
|
||||
// Recursively call the call method with the tool call message
|
||||
// conversation that contains the call responses.
|
||||
return this.call(new Prompt(toolCallConversation, prompt.getOptions()));
|
||||
if (ToolCallingChatOptions.isInternalToolExecutionEnabled(prompt.getOptions()) && response != null
|
||||
&& response.hasToolCalls()) {
|
||||
var toolExecutionResult = this.toolCallingManager.executeToolCalls(prompt, response);
|
||||
if (toolExecutionResult.returnDirect()) {
|
||||
// Return tool execution result directly to the client.
|
||||
return ChatResponse.builder()
|
||||
.from(response)
|
||||
.generations(ToolExecutionResult.buildGenerations(toolExecutionResult))
|
||||
.build();
|
||||
}
|
||||
else {
|
||||
// Send the tool execution result back to the model.
|
||||
return this.internalCall(new Prompt(toolExecutionResult.conversationHistory(), prompt.getOptions()));
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
|
||||
}
|
||||
|
||||
Prompt buildRequestPrompt(Prompt prompt) {
|
||||
// Process runtime options
|
||||
VertexAiGeminiChatOptions runtimeOptions = null;
|
||||
if (prompt.getOptions() != null) {
|
||||
if (prompt.getOptions() instanceof ToolCallingChatOptions toolCallingChatOptions) {
|
||||
runtimeOptions = ModelOptionsUtils.copyToTarget(toolCallingChatOptions, ToolCallingChatOptions.class,
|
||||
VertexAiGeminiChatOptions.class);
|
||||
}
|
||||
else if (prompt.getOptions() instanceof FunctionCallingOptions functionCallingOptions) {
|
||||
runtimeOptions = ModelOptionsUtils.copyToTarget(functionCallingOptions, FunctionCallingOptions.class,
|
||||
VertexAiGeminiChatOptions.class);
|
||||
}
|
||||
else {
|
||||
runtimeOptions = ModelOptionsUtils.copyToTarget(prompt.getOptions(), ChatOptions.class,
|
||||
VertexAiGeminiChatOptions.class);
|
||||
}
|
||||
}
|
||||
|
||||
// Define request options by merging runtime options and default options
|
||||
VertexAiGeminiChatOptions requestOptions = ModelOptionsUtils.merge(runtimeOptions, this.defaultOptions,
|
||||
VertexAiGeminiChatOptions.class);
|
||||
|
||||
// Merge @JsonIgnore-annotated options explicitly since they are ignored by
|
||||
// Jackson, used by ModelOptionsUtils.
|
||||
if (runtimeOptions != null) {
|
||||
requestOptions.setInternalToolExecutionEnabled(
|
||||
ModelOptionsUtils.mergeOption(runtimeOptions.isInternalToolExecutionEnabled(),
|
||||
this.defaultOptions.isInternalToolExecutionEnabled()));
|
||||
requestOptions.setToolNames(ToolCallingChatOptions.mergeToolNames(runtimeOptions.getToolNames(),
|
||||
this.defaultOptions.getToolNames()));
|
||||
requestOptions.setToolCallbacks(ToolCallingChatOptions.mergeToolCallbacks(runtimeOptions.getToolCallbacks(),
|
||||
this.defaultOptions.getToolCallbacks()));
|
||||
requestOptions.setToolContext(ToolCallingChatOptions.mergeToolContext(runtimeOptions.getToolContext(),
|
||||
this.defaultOptions.getToolContext()));
|
||||
|
||||
requestOptions.setGoogleSearchRetrieval(ModelOptionsUtils.mergeOption(
|
||||
runtimeOptions.getGoogleSearchRetrieval(), this.defaultOptions.getGoogleSearchRetrieval()));
|
||||
requestOptions.setSafetySettings(ModelOptionsUtils.mergeOption(runtimeOptions.getSafetySettings(),
|
||||
this.defaultOptions.getSafetySettings()));
|
||||
}
|
||||
else {
|
||||
requestOptions.setInternalToolExecutionEnabled(this.defaultOptions.isInternalToolExecutionEnabled());
|
||||
requestOptions.setToolNames(this.defaultOptions.getToolNames());
|
||||
requestOptions.setToolCallbacks(this.defaultOptions.getToolCallbacks());
|
||||
requestOptions.setToolContext(this.defaultOptions.getToolContext());
|
||||
|
||||
requestOptions.setGoogleSearchRetrieval(this.defaultOptions.getGoogleSearchRetrieval());
|
||||
requestOptions.setSafetySettings(this.defaultOptions.getSafetySettings());
|
||||
}
|
||||
|
||||
ToolCallingChatOptions.validateToolCallbacks(requestOptions.getToolCallbacks());
|
||||
|
||||
return new Prompt(prompt.getInstructions(), requestOptions);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Flux<ChatResponse> stream(Prompt prompt) {
|
||||
var requestPrompt = this.buildRequestPrompt(prompt);
|
||||
return this.internalStream(requestPrompt);
|
||||
}
|
||||
|
||||
public Flux<ChatResponse> internalStream(Prompt prompt) {
|
||||
return Flux.deferContextual(contextView -> {
|
||||
VertexAiGeminiChatOptions vertexAiGeminiChatOptions = vertexAiGeminiChatOptions(prompt);
|
||||
|
||||
ChatModelObservationContext observationContext = ChatModelObservationContext.builder()
|
||||
.prompt(prompt)
|
||||
.provider(VertexAiGeminiConstants.PROVIDER_NAME)
|
||||
.requestOptions(vertexAiGeminiChatOptions)
|
||||
.requestOptions(prompt.getOptions())
|
||||
.build();
|
||||
|
||||
Observation observation = ChatModelObservationDocumentation.CHAT_MODEL_OPERATION.observation(
|
||||
@@ -345,41 +479,56 @@ public class VertexAiGeminiChatModel extends AbstractToolCallSupport implements
|
||||
this.observationRegistry);
|
||||
|
||||
observation.parentObservation(contextView.getOrDefault(ObservationThreadLocalAccessor.KEY, null)).start();
|
||||
var request = createGeminiRequest(prompt, vertexAiGeminiChatOptions);
|
||||
|
||||
var request = createGeminiRequest(prompt);
|
||||
|
||||
try {
|
||||
ResponseStream<GenerateContentResponse> responseStream = request.model
|
||||
.generateContentStream(request.contents);
|
||||
|
||||
return Flux.fromStream(responseStream.stream()).switchMap(response -> {
|
||||
Flux<ChatResponse> chatResponse1 = Flux.fromStream(responseStream.stream())
|
||||
.switchMap(response2 -> Mono.just(response2).map(response -> {
|
||||
|
||||
List<Generation> generations = response.getCandidatesList()
|
||||
.stream()
|
||||
.map(this::responseCandidateToGeneration)
|
||||
.flatMap(List::stream)
|
||||
.toList();
|
||||
List<Generation> generations = response.getCandidatesList()
|
||||
.stream()
|
||||
.map(this::responseCandidateToGeneration)
|
||||
.flatMap(List::stream)
|
||||
.toList();
|
||||
|
||||
ChatResponse chatResponse = new ChatResponse(generations, toChatResponseMetadata(response));
|
||||
return new ChatResponse(generations, toChatResponseMetadata(response));
|
||||
|
||||
if (!isProxyToolCalls(prompt, this.defaultOptions) && isToolCall(chatResponse,
|
||||
Set.of(FinishReason.STOP.name(), FinishReason.FINISH_REASON_UNSPECIFIED.name()))) {
|
||||
var toolCallConversation = handleToolCalls(prompt, chatResponse);
|
||||
// Recursively call the stream method with the tool call message
|
||||
// conversation that contains the call responses.
|
||||
return this.stream(new Prompt(toolCallConversation, prompt.getOptions()));
|
||||
}));
|
||||
|
||||
// @formatter:off
|
||||
Flux<ChatResponse> chatResponseFlux = chatResponse1.flatMap(response -> {
|
||||
if (ToolCallingChatOptions.isInternalToolExecutionEnabled(prompt.getOptions()) && response.hasToolCalls()) {
|
||||
var toolExecutionResult = this.toolCallingManager.executeToolCalls(prompt, response);
|
||||
if (toolExecutionResult.returnDirect()) {
|
||||
// Return tool execution result directly to the client.
|
||||
return Flux.just(ChatResponse.builder().from(response)
|
||||
.generations(ToolExecutionResult.buildGenerations(toolExecutionResult))
|
||||
.build());
|
||||
} else {
|
||||
// Send the tool execution result back to the model.
|
||||
return this.internalStream(new Prompt(toolExecutionResult.conversationHistory(), prompt.getOptions()));
|
||||
}
|
||||
}
|
||||
else {
|
||||
return Flux.just(response);
|
||||
}
|
||||
})
|
||||
.doOnError(observation::error)
|
||||
.doFinally(s -> observation.stop())
|
||||
.contextWrite(ctx -> ctx.put(ObservationThreadLocalAccessor.KEY, observation));
|
||||
// @formatter:on;
|
||||
|
||||
Flux<ChatResponse> chatResponseFlux = Flux.just(chatResponse)
|
||||
.doOnError(observation::error)
|
||||
.doFinally(s -> observation.stop())
|
||||
.contextWrite(ctx -> ctx.put(ObservationThreadLocalAccessor.KEY, observation));
|
||||
return new MessageAggregator().aggregate(chatResponseFlux, observationContext::setResponse);
|
||||
|
||||
return new MessageAggregator().aggregate(chatResponseFlux, observationContext::setResponse);
|
||||
});
|
||||
}
|
||||
catch (Exception e) {
|
||||
throw new RuntimeException("Failed to generate content", e);
|
||||
}
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
@@ -448,54 +597,40 @@ public class VertexAiGeminiChatModel extends AbstractToolCallSupport implements
|
||||
VertexAiGeminiChatOptions.class);
|
||||
|
||||
return updatedRuntimeOptions;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests access to the {@link #createGeminiRequest(Prompt, VertexAiGeminiChatOptions)}
|
||||
* method.
|
||||
*/
|
||||
GeminiRequest createGeminiRequest(Prompt prompt, VertexAiGeminiChatOptions updatedRuntimeOptions) {
|
||||
GeminiRequest createGeminiRequest(Prompt prompt) {
|
||||
|
||||
Set<String> functionsForThisRequest = new HashSet<>();
|
||||
VertexAiGeminiChatOptions requestOptions = (VertexAiGeminiChatOptions) prompt.getOptions();
|
||||
|
||||
var generativeModelBuilder = new GenerativeModel.Builder().setVertexAi(this.vertexAI)
|
||||
.setSafetySettings(toGeminiSafetySettings(requestOptions.getSafetySettings()));
|
||||
|
||||
if (requestOptions.getModel() != null) {
|
||||
generativeModelBuilder.setModelName(requestOptions.getModel());
|
||||
}
|
||||
else {
|
||||
generativeModelBuilder.setModelName(this.defaultOptions.getModel());
|
||||
}
|
||||
|
||||
GenerationConfig generationConfig = this.generationConfig;
|
||||
|
||||
var generativeModelBuilder = new GenerativeModel.Builder().setModelName(this.defaultOptions.getModel())
|
||||
.setVertexAi(this.vertexAI)
|
||||
.setSafetySettings(toGeminiSafetySettings(this.defaultOptions.getSafetySettings()));
|
||||
|
||||
if (prompt.getOptions() != null) {
|
||||
if (prompt.getOptions() instanceof FunctionCallingOptions functionCallingOptions) {
|
||||
updatedRuntimeOptions = ModelOptionsUtils.copyToTarget(functionCallingOptions,
|
||||
FunctionCallingOptions.class, VertexAiGeminiChatOptions.class);
|
||||
}
|
||||
else {
|
||||
updatedRuntimeOptions = ModelOptionsUtils.copyToTarget(prompt.getOptions(), ChatOptions.class,
|
||||
VertexAiGeminiChatOptions.class);
|
||||
}
|
||||
functionsForThisRequest.addAll(runtimeFunctionCallbackConfigurations(updatedRuntimeOptions));
|
||||
}
|
||||
|
||||
if (!CollectionUtils.isEmpty(this.defaultOptions.getFunctions())) {
|
||||
functionsForThisRequest.addAll(this.defaultOptions.getFunctions());
|
||||
}
|
||||
|
||||
if (updatedRuntimeOptions != null) {
|
||||
|
||||
if (StringUtils.hasText(updatedRuntimeOptions.getModel())
|
||||
&& !updatedRuntimeOptions.getModel().equals(this.defaultOptions.getModel())) {
|
||||
// Override model name
|
||||
generativeModelBuilder.setModelName(updatedRuntimeOptions.getModel());
|
||||
}
|
||||
|
||||
generationConfig = toGenerationConfig(updatedRuntimeOptions);
|
||||
if (requestOptions != null) {
|
||||
generationConfig = toGenerationConfig(requestOptions);
|
||||
}
|
||||
|
||||
// Add the enabled functions definitions to the request's tools parameter.
|
||||
List<Tool> tools = new ArrayList<>();
|
||||
if (!CollectionUtils.isEmpty(functionsForThisRequest)) {
|
||||
tools.addAll(this.getFunctionTools(functionsForThisRequest));
|
||||
List<ToolDefinition> toolDefinitions = this.toolCallingManager.resolveToolDefinitions(requestOptions);
|
||||
if (!CollectionUtils.isEmpty(toolDefinitions)) {
|
||||
final List<FunctionDeclaration> functionDeclarations = toolDefinitions.stream()
|
||||
.map(toolDefinition -> FunctionDeclaration.newBuilder()
|
||||
.setName(toolDefinition.name())
|
||||
.setDescription(toolDefinition.description())
|
||||
.setParameters(jsonToSchema(toolDefinition.inputSchema()))
|
||||
.build())
|
||||
.toList();
|
||||
tools.add(Tool.newBuilder().addAllFunctionDeclarations(functionDeclarations).build());
|
||||
}
|
||||
|
||||
if (prompt.getOptions() instanceof VertexAiGeminiChatOptions options && options.getGoogleSearchRetrieval()) {
|
||||
@@ -505,13 +640,13 @@ public class VertexAiGeminiChatModel extends AbstractToolCallSupport implements
|
||||
.build();
|
||||
tools.add(googleSearchRetrievalTool);
|
||||
}
|
||||
|
||||
if (!CollectionUtils.isEmpty(tools)) {
|
||||
generativeModelBuilder.setTools(tools);
|
||||
}
|
||||
|
||||
if (prompt.getOptions() instanceof VertexAiGeminiChatOptions options
|
||||
&& !CollectionUtils.isEmpty(options.getSafetySettings())) {
|
||||
generativeModelBuilder.setSafetySettings(toGeminiSafetySettings(options.getSafetySettings()));
|
||||
if (!CollectionUtils.isEmpty(requestOptions.getSafetySettings())) {
|
||||
generativeModelBuilder.setSafetySettings(toGeminiSafetySettings(requestOptions.getSafetySettings()));
|
||||
}
|
||||
|
||||
generativeModelBuilder.setGenerationConfig(generationConfig);
|
||||
@@ -582,22 +717,6 @@ public class VertexAiGeminiChatModel extends AbstractToolCallSupport implements
|
||||
.toList();
|
||||
}
|
||||
|
||||
private List<Tool> getFunctionTools(Set<String> functionNames) {
|
||||
|
||||
final var tool = Tool.newBuilder();
|
||||
|
||||
final List<FunctionDeclaration> functionDeclarations = this.resolveFunctionCallbacks(functionNames)
|
||||
.stream()
|
||||
.map(functionCallback -> FunctionDeclaration.newBuilder()
|
||||
.setName(functionCallback.getName())
|
||||
.setDescription(functionCallback.getDescription())
|
||||
.setParameters(jsonToSchema(functionCallback.getInputTypeSchema()))
|
||||
.build())
|
||||
.toList();
|
||||
tool.addAllFunctionDeclarations(functionDeclarations);
|
||||
return List.of(tool.build());
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the content response based on the provided Gemini request. Package
|
||||
* protected for testing purposes.
|
||||
@@ -662,9 +781,15 @@ public class VertexAiGeminiChatModel extends AbstractToolCallSupport implements
|
||||
|
||||
GEMINI_PRO("gemini-pro"),
|
||||
|
||||
GEMINI_1_5_PRO("gemini-1.5-pro-001"),
|
||||
GEMINI_1_5_PRO("gemini-1.5-pro-002"),
|
||||
|
||||
GEMINI_1_5_FLASH("gemini-1.5-flash-001");
|
||||
GEMINI_1_5_FLASH("gemini-1.5-flash-002"),
|
||||
|
||||
GEMINI_1_5_FLASH_8B("gemini-1.5-flash-8b-001"),
|
||||
|
||||
GEMINI_2_0_FLASH("gemini-2.0-flash"),
|
||||
|
||||
GEMINI_2_0_FLASH_LIGHT("gemini-2.0-flash-lite-preview-02-05");
|
||||
|
||||
public final String value;
|
||||
|
||||
@@ -688,4 +813,95 @@ public class VertexAiGeminiChatModel extends AbstractToolCallSupport implements
|
||||
|
||||
}
|
||||
|
||||
public static Builder builder() {
|
||||
return new Builder();
|
||||
}
|
||||
|
||||
public static class Builder {
|
||||
|
||||
private VertexAI vertexAI;
|
||||
|
||||
private VertexAiGeminiChatOptions defaultOptions = VertexAiGeminiChatOptions.builder()
|
||||
.temperature(0.7)
|
||||
.topP(1.0)
|
||||
.model(VertexAiGeminiChatModel.ChatModel.GEMINI_2_0_FLASH)
|
||||
.build();
|
||||
|
||||
private ToolCallingManager toolCallingManager;
|
||||
|
||||
private FunctionCallbackResolver functionCallbackResolver;
|
||||
|
||||
private List<FunctionCallback> toolFunctionCallbacks;
|
||||
|
||||
private RetryTemplate retryTemplate = RetryUtils.DEFAULT_RETRY_TEMPLATE;
|
||||
|
||||
private ObservationRegistry observationRegistry = ObservationRegistry.NOOP;
|
||||
|
||||
private Builder() {
|
||||
}
|
||||
|
||||
public Builder vertexAI(VertexAI vertexAI) {
|
||||
this.vertexAI = vertexAI;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder defaultOptions(VertexAiGeminiChatOptions defaultOptions) {
|
||||
this.defaultOptions = defaultOptions;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder toolCallingManager(ToolCallingManager toolCallingManager) {
|
||||
this.toolCallingManager = toolCallingManager;
|
||||
return this;
|
||||
}
|
||||
|
||||
@Deprecated
|
||||
public Builder functionCallbackResolver(FunctionCallbackResolver functionCallbackResolver) {
|
||||
this.functionCallbackResolver = functionCallbackResolver;
|
||||
return this;
|
||||
}
|
||||
|
||||
@Deprecated
|
||||
public Builder toolFunctionCallbacks(List<FunctionCallback> toolFunctionCallbacks) {
|
||||
this.toolFunctionCallbacks = toolFunctionCallbacks;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder retryTemplate(RetryTemplate retryTemplate) {
|
||||
this.retryTemplate = retryTemplate;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder observationRegistry(ObservationRegistry observationRegistry) {
|
||||
this.observationRegistry = observationRegistry;
|
||||
return this;
|
||||
}
|
||||
|
||||
public VertexAiGeminiChatModel build() {
|
||||
if (toolCallingManager != null) {
|
||||
Assert.isNull(functionCallbackResolver,
|
||||
"functionCallbackResolver cannot be set when toolCallingManager is set");
|
||||
Assert.isNull(toolFunctionCallbacks,
|
||||
"toolFunctionCallbacks cannot be set when toolCallingManager is set");
|
||||
|
||||
return new VertexAiGeminiChatModel(vertexAI, defaultOptions, toolCallingManager, retryTemplate,
|
||||
observationRegistry);
|
||||
}
|
||||
|
||||
if (functionCallbackResolver != null) {
|
||||
Assert.isNull(toolCallingManager,
|
||||
"toolCallingManager cannot be set when functionCallbackResolver is set");
|
||||
List<FunctionCallback> toolCallbacks = this.toolFunctionCallbacks != null ? this.toolFunctionCallbacks
|
||||
: List.of();
|
||||
|
||||
return new VertexAiGeminiChatModel(vertexAI, defaultOptions, functionCallbackResolver, toolCallbacks,
|
||||
retryTemplate, observationRegistry);
|
||||
}
|
||||
|
||||
return new VertexAiGeminiChatModel(vertexAI, defaultOptions, DEFAULT_TOOL_CALLING_MANAGER, retryTemplate,
|
||||
observationRegistry);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2023-2024 the original author or authors.
|
||||
* Copyright 2023-2025 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.
|
||||
@@ -29,11 +29,12 @@ import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude.Include;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
import org.springframework.ai.chat.prompt.ChatOptions;
|
||||
import org.springframework.ai.model.function.FunctionCallback;
|
||||
import org.springframework.ai.model.function.FunctionCallingOptions;
|
||||
import org.springframework.ai.model.tool.ToolCallingChatOptions;
|
||||
import org.springframework.ai.tool.ToolCallback;
|
||||
import org.springframework.ai.vertexai.gemini.VertexAiGeminiChatModel.ChatModel;
|
||||
import org.springframework.ai.vertexai.gemini.common.VertexAiGeminiSafetySetting;
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.util.Assert;
|
||||
|
||||
/**
|
||||
@@ -46,7 +47,7 @@ import org.springframework.util.Assert;
|
||||
* @since 1.0.0
|
||||
*/
|
||||
@JsonInclude(Include.NON_NULL)
|
||||
public class VertexAiGeminiChatOptions implements FunctionCallingOptions {
|
||||
public class VertexAiGeminiChatOptions implements ToolCallingChatOptions {
|
||||
|
||||
// https://cloud.google.com/vertex-ai/docs/reference/rest/v1/GenerationConfig
|
||||
|
||||
@@ -95,41 +96,37 @@ public class VertexAiGeminiChatOptions implements FunctionCallingOptions {
|
||||
private @JsonProperty("responseMimeType") String responseMimeType;
|
||||
|
||||
/**
|
||||
* Tool Function Callbacks to register with the ChatModel.
|
||||
* For Prompt Options the functionCallbacks are automatically enabled for the duration of the prompt execution.
|
||||
* For Default Options the functionCallbacks are registered but disabled by default. Use the enableFunctions to set the functions
|
||||
* from the registry to be used by the ChatModel chat completion requests.
|
||||
* Collection of {@link ToolCallback}s to be used for tool calling in the chat
|
||||
* completion requests.
|
||||
*/
|
||||
@JsonIgnore
|
||||
private List<FunctionCallback> functionCallbacks = new ArrayList<>();
|
||||
private List<FunctionCallback> toolCallbacks = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* List of functions, identified by their names, to configure for function calling in
|
||||
* the chat completion requests.
|
||||
* Functions with those names must exist in the functionCallbacks registry.
|
||||
* The {@link #functionCallbacks} from the PromptOptions are automatically enabled for the duration of the prompt execution.
|
||||
*
|
||||
* Note that function enabled with the default options are enabled for all chat completion requests. This could impact the token count and the billing.
|
||||
* If the functions is set in a prompt options, then the enabled functions are only active for the duration of this prompt execution.
|
||||
* Collection of tool names to be resolved at runtime and used for tool calling in the
|
||||
* chat completion requests.
|
||||
*/
|
||||
@JsonIgnore
|
||||
private Set<String> functions = new HashSet<>();
|
||||
private Set<String> toolNames = new HashSet<>();
|
||||
|
||||
/**
|
||||
* Whether to enable the tool execution lifecycle internally in ChatModel.
|
||||
*/
|
||||
@JsonIgnore
|
||||
private Boolean internalToolExecutionEnabled;
|
||||
|
||||
@JsonIgnore
|
||||
private Map<String, Object> toolContext = new HashMap<>();
|
||||
|
||||
/**
|
||||
* Use Google search Grounding feature
|
||||
*/
|
||||
@JsonIgnore
|
||||
private boolean googleSearchRetrieval = false;
|
||||
private Boolean googleSearchRetrieval = false;
|
||||
|
||||
@JsonIgnore
|
||||
private List<VertexAiGeminiSafetySetting> safetySettings = new ArrayList<>();
|
||||
|
||||
@JsonIgnore
|
||||
private Boolean proxyToolCalls;
|
||||
|
||||
@JsonIgnore
|
||||
private Map<String, Object> toolContext;
|
||||
|
||||
public static Builder builder() {
|
||||
return new Builder();
|
||||
}
|
||||
@@ -145,13 +142,13 @@ public class VertexAiGeminiChatOptions implements FunctionCallingOptions {
|
||||
options.setCandidateCount(fromOptions.getCandidateCount());
|
||||
options.setMaxOutputTokens(fromOptions.getMaxOutputTokens());
|
||||
options.setModel(fromOptions.getModel());
|
||||
options.setFunctionCallbacks(fromOptions.getFunctionCallbacks());
|
||||
options.setToolCallbacks(fromOptions.getToolCallbacks());
|
||||
options.setResponseMimeType(fromOptions.getResponseMimeType());
|
||||
options.setFunctions(fromOptions.getFunctions());
|
||||
options.setToolNames(fromOptions.getToolNames());
|
||||
options.setResponseMimeType(fromOptions.getResponseMimeType());
|
||||
options.setGoogleSearchRetrieval(fromOptions.getGoogleSearchRetrieval());
|
||||
options.setSafetySettings(fromOptions.getSafetySettings());
|
||||
options.setProxyToolCalls(fromOptions.getProxyToolCalls());
|
||||
options.setInternalToolExecutionEnabled(fromOptions.isInternalToolExecutionEnabled());
|
||||
options.setToolContext(fromOptions.getToolContext());
|
||||
return options;
|
||||
}
|
||||
@@ -236,20 +233,67 @@ public class VertexAiGeminiChatOptions implements FunctionCallingOptions {
|
||||
this.responseMimeType = mimeType;
|
||||
}
|
||||
|
||||
@Override
|
||||
@JsonIgnore
|
||||
@Deprecated
|
||||
public List<FunctionCallback> getFunctionCallbacks() {
|
||||
return this.functionCallbacks;
|
||||
return this.getToolCallbacks();
|
||||
}
|
||||
|
||||
@Override
|
||||
@JsonIgnore
|
||||
@Deprecated
|
||||
public void setFunctionCallbacks(List<FunctionCallback> functionCallbacks) {
|
||||
this.functionCallbacks = functionCallbacks;
|
||||
this.setToolCallbacks(functionCallbacks);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<FunctionCallback> getToolCallbacks() {
|
||||
return this.toolCallbacks;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setToolCallbacks(List<FunctionCallback> toolCallbacks) {
|
||||
Assert.notNull(toolCallbacks, "toolCallbacks cannot be null");
|
||||
Assert.noNullElements(toolCallbacks, "toolCallbacks cannot contain null elements");
|
||||
this.toolCallbacks = toolCallbacks;
|
||||
}
|
||||
|
||||
@Override
|
||||
@JsonIgnore
|
||||
@Deprecated
|
||||
public Set<String> getFunctions() {
|
||||
return this.functions;
|
||||
return this.getToolNames();
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
@Deprecated
|
||||
public void setFunctions(Set<String> functions) {
|
||||
this.functions = functions;
|
||||
this.setToolNames(functions);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<String> getToolNames() {
|
||||
return this.toolNames;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setToolNames(Set<String> toolNames) {
|
||||
Assert.notNull(toolNames, "toolNames cannot be null");
|
||||
Assert.noNullElements(toolNames, "toolNames cannot contain null elements");
|
||||
toolNames.forEach(tool -> Assert.hasText(tool, "toolNames cannot contain empty elements"));
|
||||
this.toolNames = toolNames;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Nullable
|
||||
public Boolean isInternalToolExecutionEnabled() {
|
||||
return internalToolExecutionEnabled;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setInternalToolExecutionEnabled(@Nullable Boolean internalToolExecutionEnabled) {
|
||||
this.internalToolExecutionEnabled = internalToolExecutionEnabled;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -264,11 +308,11 @@ public class VertexAiGeminiChatOptions implements FunctionCallingOptions {
|
||||
return null;
|
||||
}
|
||||
|
||||
public boolean getGoogleSearchRetrieval() {
|
||||
public Boolean getGoogleSearchRetrieval() {
|
||||
return this.googleSearchRetrieval;
|
||||
}
|
||||
|
||||
public void setGoogleSearchRetrieval(boolean googleSearchRetrieval) {
|
||||
public void setGoogleSearchRetrieval(Boolean googleSearchRetrieval) {
|
||||
this.googleSearchRetrieval = googleSearchRetrieval;
|
||||
}
|
||||
|
||||
@@ -281,13 +325,17 @@ public class VertexAiGeminiChatOptions implements FunctionCallingOptions {
|
||||
this.safetySettings = safetySettings;
|
||||
}
|
||||
|
||||
@Deprecated
|
||||
@Override
|
||||
@JsonIgnore
|
||||
public Boolean getProxyToolCalls() {
|
||||
return this.proxyToolCalls;
|
||||
return this.internalToolExecutionEnabled != null ? !this.internalToolExecutionEnabled : null;
|
||||
}
|
||||
|
||||
@Deprecated
|
||||
@JsonIgnore
|
||||
public void setProxyToolCalls(Boolean proxyToolCalls) {
|
||||
this.proxyToolCalls = proxyToolCalls;
|
||||
this.internalToolExecutionEnabled = proxyToolCalls != null ? !proxyToolCalls : null;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -314,18 +362,18 @@ public class VertexAiGeminiChatOptions implements FunctionCallingOptions {
|
||||
&& Objects.equals(this.topK, that.topK) && Objects.equals(this.candidateCount, that.candidateCount)
|
||||
&& Objects.equals(this.maxOutputTokens, that.maxOutputTokens) && Objects.equals(this.model, that.model)
|
||||
&& Objects.equals(this.responseMimeType, that.responseMimeType)
|
||||
&& Objects.equals(this.functionCallbacks, that.functionCallbacks)
|
||||
&& Objects.equals(this.functions, that.functions)
|
||||
&& Objects.equals(this.toolCallbacks, that.toolCallbacks)
|
||||
&& Objects.equals(this.toolNames, that.toolNames)
|
||||
&& Objects.equals(this.safetySettings, that.safetySettings)
|
||||
&& Objects.equals(this.proxyToolCalls, that.proxyToolCalls)
|
||||
&& Objects.equals(this.internalToolExecutionEnabled, that.internalToolExecutionEnabled)
|
||||
&& Objects.equals(this.toolContext, that.toolContext);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(this.stopSequences, this.temperature, this.topP, this.topK, this.candidateCount,
|
||||
this.maxOutputTokens, this.model, this.responseMimeType, this.functionCallbacks, this.functions,
|
||||
this.googleSearchRetrieval, this.safetySettings, this.proxyToolCalls, this.toolContext);
|
||||
this.maxOutputTokens, this.model, this.responseMimeType, this.toolCallbacks, this.toolNames,
|
||||
this.googleSearchRetrieval, this.safetySettings, this.internalToolExecutionEnabled, this.toolContext);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -333,9 +381,9 @@ public class VertexAiGeminiChatOptions implements FunctionCallingOptions {
|
||||
return "VertexAiGeminiChatOptions{" + "stopSequences=" + this.stopSequences + ", temperature="
|
||||
+ this.temperature + ", topP=" + this.topP + ", topK=" + this.topK + ", candidateCount="
|
||||
+ this.candidateCount + ", maxOutputTokens=" + this.maxOutputTokens + ", model='" + this.model + '\''
|
||||
+ ", responseMimeType='" + this.responseMimeType + '\'' + ", functionCallbacks="
|
||||
+ this.functionCallbacks + ", functions=" + this.functions + ", googleSearchRetrieval="
|
||||
+ this.googleSearchRetrieval + ", safetySettings=" + this.safetySettings + '}';
|
||||
+ ", responseMimeType='" + this.responseMimeType + '\'' + ", toolCallbacks=" + this.toolCallbacks
|
||||
+ ", toolNames=" + this.toolNames + ", googleSearchRetrieval=" + this.googleSearchRetrieval
|
||||
+ ", safetySettings=" + this.safetySettings + '}';
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -343,67 +391,6 @@ public class VertexAiGeminiChatOptions implements FunctionCallingOptions {
|
||||
return fromOptions(this);
|
||||
}
|
||||
|
||||
public FunctionCallingOptions merge(ChatOptions options) {
|
||||
VertexAiGeminiChatOptions.Builder builder = VertexAiGeminiChatOptions.builder();
|
||||
|
||||
// Merge chat-specific options
|
||||
builder.model(options.getModel() != null ? options.getModel() : this.getModel())
|
||||
.maxOutputTokens(options.getMaxTokens() != null ? options.getMaxTokens() : this.getMaxOutputTokens())
|
||||
.stopSequences(options.getStopSequences() != null ? options.getStopSequences() : this.getStopSequences())
|
||||
.temperature(options.getTemperature() != null ? options.getTemperature() : this.getTemperature())
|
||||
.topP(options.getTopP() != null ? options.getTopP() : this.getTopP())
|
||||
.topK(options.getTopK() != null ? options.getTopK() : this.getTopK());
|
||||
|
||||
// Try to get function-specific properties if options is a FunctionCallingOptions
|
||||
if (options instanceof FunctionCallingOptions functionOptions) {
|
||||
builder.proxyToolCalls(functionOptions.getProxyToolCalls() != null ? functionOptions.getProxyToolCalls()
|
||||
: this.proxyToolCalls);
|
||||
|
||||
Set<String> functions = new HashSet<>();
|
||||
if (this.functions != null) {
|
||||
functions.addAll(this.functions);
|
||||
}
|
||||
if (functionOptions.getFunctions() != null) {
|
||||
functions.addAll(functionOptions.getFunctions());
|
||||
}
|
||||
builder.functions(functions);
|
||||
|
||||
List<FunctionCallback> functionCallbacks = new ArrayList<>();
|
||||
if (this.functionCallbacks != null) {
|
||||
functionCallbacks.addAll(this.functionCallbacks);
|
||||
}
|
||||
if (functionOptions.getFunctionCallbacks() != null) {
|
||||
functionCallbacks.addAll(functionOptions.getFunctionCallbacks());
|
||||
}
|
||||
builder.functionCallbacks(functionCallbacks);
|
||||
|
||||
Map<String, Object> context = new HashMap<>();
|
||||
if (this.toolContext != null) {
|
||||
context.putAll(this.toolContext);
|
||||
}
|
||||
if (functionOptions.getToolContext() != null) {
|
||||
context.putAll(functionOptions.getToolContext());
|
||||
}
|
||||
builder.toolContext(context);
|
||||
}
|
||||
else {
|
||||
// If not a FunctionCallingOptions, preserve current function-specific
|
||||
// properties
|
||||
builder.proxyToolCalls(this.proxyToolCalls);
|
||||
builder.functions(this.functions != null ? new HashSet<>(this.functions) : null);
|
||||
builder.functionCallbacks(this.functionCallbacks != null ? new ArrayList<>(this.functionCallbacks) : null);
|
||||
builder.toolContext(this.toolContext != null ? new HashMap<>(this.toolContext) : null);
|
||||
}
|
||||
|
||||
// Preserve Vertex AI Gemini-specific properties
|
||||
builder.candidateCount(this.candidateCount)
|
||||
.responseMimeType(this.responseMimeType)
|
||||
.googleSearchRetrieval(this.googleSearchRetrieval)
|
||||
.safetySettings(this.safetySettings != null ? new ArrayList<>(this.safetySettings) : null);
|
||||
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
public enum TransportType {
|
||||
|
||||
GRPC, REST
|
||||
@@ -460,20 +447,35 @@ public class VertexAiGeminiChatOptions implements FunctionCallingOptions {
|
||||
return this;
|
||||
}
|
||||
|
||||
@Deprecated
|
||||
public Builder functionCallbacks(List<FunctionCallback> functionCallbacks) {
|
||||
this.options.functionCallbacks = functionCallbacks;
|
||||
return toolCallbacks(functionCallbacks);
|
||||
}
|
||||
|
||||
public Builder toolCallbacks(List<FunctionCallback> toolCallbacks) {
|
||||
this.options.toolCallbacks = toolCallbacks;
|
||||
return this;
|
||||
}
|
||||
|
||||
@Deprecated
|
||||
public Builder functions(Set<String> functionNames) {
|
||||
Assert.notNull(functionNames, "Function names must not be null");
|
||||
this.options.functions = functionNames;
|
||||
return this.toolNames(functionNames);
|
||||
}
|
||||
|
||||
public Builder toolNames(Set<String> toolNames) {
|
||||
Assert.notNull(toolNames, "Function names must not be null");
|
||||
this.options.toolNames = toolNames;
|
||||
return this;
|
||||
}
|
||||
|
||||
@Deprecated
|
||||
public Builder function(String functionName) {
|
||||
Assert.hasText(functionName, "Function name must not be empty");
|
||||
this.options.functions.add(functionName);
|
||||
return this.toolName(functionName);
|
||||
}
|
||||
|
||||
public Builder toolName(String toolName) {
|
||||
Assert.hasText(toolName, "Function name must not be empty");
|
||||
this.options.toolNames.add(toolName);
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -488,8 +490,13 @@ public class VertexAiGeminiChatOptions implements FunctionCallingOptions {
|
||||
return this;
|
||||
}
|
||||
|
||||
@Deprecated
|
||||
public Builder proxyToolCalls(boolean proxyToolCalls) {
|
||||
this.options.proxyToolCalls = proxyToolCalls;
|
||||
return this.internalToolExecutionEnabled(proxyToolCalls);
|
||||
}
|
||||
|
||||
public Builder internalToolExecutionEnabled(boolean internalToolExecutionEnabled) {
|
||||
this.options.internalToolExecutionEnabled = internalToolExecutionEnabled;
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
/*
|
||||
* Copyright 2025 - 2025 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.schema;
|
||||
|
||||
/**
|
||||
* @author Christian Tzolov
|
||||
* @since 1.0.0
|
||||
*/
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
|
||||
import org.springframework.util.Assert;
|
||||
|
||||
/**
|
||||
* Utility class for converting JSON Schema to OpenAPI schema format.
|
||||
*/
|
||||
public final class JsonSchemaConverter {
|
||||
|
||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||
|
||||
private JsonSchemaConverter() {
|
||||
// Prevent instantiation
|
||||
}
|
||||
|
||||
public static ObjectNode fromJson(String jsonString) {
|
||||
try {
|
||||
return (ObjectNode) OBJECT_MAPPER.readTree(jsonString);
|
||||
}
|
||||
catch (Exception e) {
|
||||
throw new RuntimeException("Failed to parse JSON: " + jsonString, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a JSON Schema ObjectNode to OpenAPI schema format.
|
||||
* @param jsonSchemaNode The input JSON Schema as ObjectNode
|
||||
* @return ObjectNode containing the OpenAPI schema
|
||||
* @throws IllegalArgumentException if jsonSchemaNode is null
|
||||
*/
|
||||
public static ObjectNode convertToOpenApiSchema(ObjectNode jsonSchemaNode) {
|
||||
Assert.notNull(jsonSchemaNode, "JSON Schema node must not be null");
|
||||
|
||||
try {
|
||||
// Convert to OpenAPI schema using our custom conversion logic
|
||||
ObjectNode openApiSchema = convertSchema(jsonSchemaNode, OBJECT_MAPPER.getNodeFactory());
|
||||
|
||||
// Add OpenAPI-specific metadata
|
||||
if (!openApiSchema.has("openapi")) {
|
||||
openApiSchema.put("openapi", "3.0.0");
|
||||
}
|
||||
|
||||
return openApiSchema;
|
||||
}
|
||||
catch (Exception e) {
|
||||
throw new IllegalStateException("Failed to convert JSON Schema to OpenAPI format: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies common properties from source to target node.
|
||||
* @param source The source ObjectNode containing JSON Schema properties
|
||||
* @param target The target ObjectNode to copy properties to
|
||||
*/
|
||||
private static void copyCommonProperties(ObjectNode source, ObjectNode target) {
|
||||
Assert.notNull(source, "Source node must not be null");
|
||||
Assert.notNull(target, "Target node must not be null");
|
||||
String[] commonProperties = {
|
||||
// Core schema properties
|
||||
"type", "format", "description", "default", "maximum", "minimum", "maxLength", "minLength", "pattern",
|
||||
"enum", "multipleOf", "uniqueItems",
|
||||
// OpenAPI specific properties
|
||||
"example", "deprecated", "readOnly", "writeOnly", "nullable", "discriminator", "xml", "externalDocs" };
|
||||
|
||||
for (String prop : commonProperties) {
|
||||
if (source.has(prop)) {
|
||||
target.set(prop, source.get(prop));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles JSON Schema specific attributes and converts them to OpenAPI format.
|
||||
* @param source The source ObjectNode containing JSON Schema
|
||||
* @param target The target ObjectNode to store OpenAPI schema
|
||||
*/
|
||||
private static void handleJsonSchemaSpecifics(ObjectNode source, ObjectNode target) {
|
||||
Assert.notNull(source, "Source node must not be null");
|
||||
Assert.notNull(target, "Target node must not be null");
|
||||
if (source.has("properties")) {
|
||||
ObjectNode properties = target.putObject("properties");
|
||||
source.get("properties").fields().forEachRemaining(entry -> {
|
||||
if (entry.getValue() instanceof ObjectNode) {
|
||||
properties.set(entry.getKey(),
|
||||
convertSchema((ObjectNode) entry.getValue(), OBJECT_MAPPER.getNodeFactory()));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Handle required array
|
||||
if (source.has("required")) {
|
||||
target.set("required", source.get("required"));
|
||||
}
|
||||
|
||||
// Convert JSON Schema specific attributes to OpenAPI equivalents
|
||||
if (source.has("additionalProperties")) {
|
||||
JsonNode additionalProps = source.get("additionalProperties");
|
||||
if (additionalProps.isBoolean()) {
|
||||
target.put("additionalProperties", additionalProps.asBoolean());
|
||||
}
|
||||
else if (additionalProps.isObject()) {
|
||||
target.set("additionalProperties",
|
||||
convertSchema((ObjectNode) additionalProps, OBJECT_MAPPER.getNodeFactory()));
|
||||
}
|
||||
}
|
||||
|
||||
// Handle arrays
|
||||
if (source.has("items")) {
|
||||
JsonNode items = source.get("items");
|
||||
if (items.isObject()) {
|
||||
target.set("items", convertSchema((ObjectNode) items, OBJECT_MAPPER.getNodeFactory()));
|
||||
}
|
||||
}
|
||||
|
||||
// Handle allOf, anyOf, oneOf
|
||||
String[] combiners = { "allOf", "anyOf", "oneOf" };
|
||||
for (String combiner : combiners) {
|
||||
if (source.has(combiner)) {
|
||||
JsonNode combinerNode = source.get(combiner);
|
||||
if (combinerNode.isArray()) {
|
||||
target.putArray(combiner).addAll((com.fasterxml.jackson.databind.node.ArrayNode) combinerNode);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively converts a JSON Schema node to OpenAPI format.
|
||||
* @param source The source ObjectNode containing JSON Schema
|
||||
* @param factory The JsonNodeFactory to create new nodes
|
||||
* @return The converted OpenAPI schema as ObjectNode
|
||||
*/
|
||||
private static ObjectNode convertSchema(ObjectNode source,
|
||||
com.fasterxml.jackson.databind.node.JsonNodeFactory factory) {
|
||||
Assert.notNull(source, "Source node must not be null");
|
||||
Assert.notNull(factory, "JsonNodeFactory must not be null");
|
||||
|
||||
ObjectNode converted = factory.objectNode();
|
||||
copyCommonProperties(source, converted);
|
||||
handleJsonSchemaSpecifics(source, converted);
|
||||
return converted;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* Copyright 2025 - 2025 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.schema;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
|
||||
import org.springframework.ai.chat.model.ChatResponse;
|
||||
import org.springframework.ai.chat.prompt.Prompt;
|
||||
import org.springframework.ai.model.tool.ToolCallingChatOptions;
|
||||
import org.springframework.ai.model.tool.ToolCallingManager;
|
||||
import org.springframework.ai.model.tool.ToolExecutionResult;
|
||||
import org.springframework.ai.tool.definition.ToolDefinition;
|
||||
import org.springframework.ai.util.json.schema.JsonSchemaGenerator;
|
||||
|
||||
/**
|
||||
* @author Christian Tzolov
|
||||
* @since 1.0.0
|
||||
*/
|
||||
|
||||
public class VertexToolCallingManager implements ToolCallingManager {
|
||||
|
||||
private final ToolCallingManager delegateToolCallingManager;
|
||||
|
||||
public VertexToolCallingManager(ToolCallingManager delegateToolCallingManager) {
|
||||
this.delegateToolCallingManager = delegateToolCallingManager;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ToolDefinition> resolveToolDefinitions(ToolCallingChatOptions chatOptions) {
|
||||
|
||||
List<ToolDefinition> toolDefinitions = delegateToolCallingManager.resolveToolDefinitions(chatOptions);
|
||||
|
||||
return toolDefinitions.stream().map(td -> {
|
||||
ObjectNode jsonSchema = JsonSchemaConverter.fromJson(td.inputSchema());
|
||||
ObjectNode openApiSchema = JsonSchemaConverter.convertToOpenApiSchema(jsonSchema);
|
||||
JsonSchemaGenerator.convertTypeValuesToUpperCase(openApiSchema);
|
||||
|
||||
return ToolDefinition.builder()
|
||||
.name(td.name())
|
||||
.description(td.description())
|
||||
.inputSchema(openApiSchema.toPrettyString())
|
||||
.build();
|
||||
}).toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ToolExecutionResult executeToolCalls(Prompt prompt, ChatResponse chatResponse) {
|
||||
return this.delegateToolCallingManager.executeToolCalls(prompt, chatResponse);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -33,8 +33,12 @@ import org.springframework.ai.chat.messages.UserMessage;
|
||||
import org.springframework.ai.chat.prompt.Prompt;
|
||||
import org.springframework.ai.model.Media;
|
||||
import org.springframework.ai.model.function.FunctionCallback;
|
||||
import org.springframework.ai.model.tool.ToolCallingChatOptions;
|
||||
import org.springframework.ai.model.tool.ToolCallingManager;
|
||||
import org.springframework.ai.tool.definition.ToolDefinition;
|
||||
import org.springframework.ai.tool.function.FunctionToolCallback;
|
||||
import org.springframework.ai.vertexai.gemini.VertexAiGeminiChatModel.GeminiRequest;
|
||||
import org.springframework.ai.vertexai.gemini.function.MockWeatherService;
|
||||
import org.springframework.ai.vertexai.gemini.tool.MockWeatherService;
|
||||
import org.springframework.util.MimeTypeUtils;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
@@ -51,10 +55,13 @@ public class CreateGeminiRequestTests {
|
||||
@Test
|
||||
public void createRequestWithChatOptions() {
|
||||
|
||||
var client = new VertexAiGeminiChatModel(this.vertexAI,
|
||||
VertexAiGeminiChatOptions.builder().model("DEFAULT_MODEL").temperature(66.6).build());
|
||||
var client = VertexAiGeminiChatModel.builder()
|
||||
.vertexAI(this.vertexAI)
|
||||
.defaultOptions(VertexAiGeminiChatOptions.builder().model("DEFAULT_MODEL").temperature(66.6).build())
|
||||
.build();
|
||||
|
||||
GeminiRequest request = client.createGeminiRequest(new Prompt("Test message content"), null);
|
||||
GeminiRequest request = client.createGeminiRequest(client
|
||||
.buildRequestPrompt(new Prompt("Test message content", VertexAiGeminiChatOptions.builder().build())));
|
||||
|
||||
assertThat(request.contents()).hasSize(1);
|
||||
|
||||
@@ -62,8 +69,8 @@ public class CreateGeminiRequestTests {
|
||||
assertThat(request.model().getModelName()).isEqualTo("DEFAULT_MODEL");
|
||||
assertThat(request.model().getGenerationConfig().getTemperature()).isEqualTo(66.6f);
|
||||
|
||||
request = client.createGeminiRequest(new Prompt("Test message content",
|
||||
VertexAiGeminiChatOptions.builder().model("PROMPT_MODEL").temperature(99.9).build()), null);
|
||||
request = client.createGeminiRequest(client.buildRequestPrompt(new Prompt("Test message content",
|
||||
VertexAiGeminiChatOptions.builder().model("PROMPT_MODEL").temperature(99.9).build())));
|
||||
|
||||
assertThat(request.contents()).hasSize(1);
|
||||
|
||||
@@ -80,10 +87,13 @@ public class CreateGeminiRequestTests {
|
||||
var userMessage = new UserMessage("User Message Text",
|
||||
List.of(Media.builder().mimeType(MimeTypeUtils.IMAGE_PNG).data(new URL("http://example.com")).build()));
|
||||
|
||||
var client = new VertexAiGeminiChatModel(this.vertexAI,
|
||||
VertexAiGeminiChatOptions.builder().model("DEFAULT_MODEL").temperature(66.6).build());
|
||||
var client = VertexAiGeminiChatModel.builder()
|
||||
.vertexAI(this.vertexAI)
|
||||
.defaultOptions(VertexAiGeminiChatOptions.builder().model("DEFAULT_MODEL").temperature(66.6).build())
|
||||
.build();
|
||||
|
||||
GeminiRequest request = client.createGeminiRequest(new Prompt(List.of(systemMessage, userMessage)), null);
|
||||
GeminiRequest request = client
|
||||
.createGeminiRequest(client.buildRequestPrompt(new Prompt(List.of(systemMessage, userMessage))));
|
||||
|
||||
assertThat(request.model().getModelName()).isEqualTo("DEFAULT_MODEL");
|
||||
assertThat(request.model().getGenerationConfig().getTemperature()).isEqualTo(66.6f);
|
||||
@@ -109,22 +119,30 @@ public class CreateGeminiRequestTests {
|
||||
|
||||
final String TOOL_FUNCTION_NAME = "CurrentWeather";
|
||||
|
||||
var client = new VertexAiGeminiChatModel(this.vertexAI,
|
||||
VertexAiGeminiChatOptions.builder().model("DEFAULT_MODEL").build());
|
||||
var toolCallingManager = ToolCallingManager.builder().build();
|
||||
|
||||
var request = client.createGeminiRequest(new Prompt("Test message content",
|
||||
var client = VertexAiGeminiChatModel.builder()
|
||||
.vertexAI(this.vertexAI)
|
||||
.defaultOptions(VertexAiGeminiChatOptions.builder().model("DEFAULT_MODEL").build())
|
||||
.toolCallingManager(toolCallingManager)
|
||||
.build();
|
||||
|
||||
var requestPrompt = client.buildRequestPrompt(new Prompt("Test message content",
|
||||
VertexAiGeminiChatOptions.builder()
|
||||
.model("PROMPT_MODEL")
|
||||
.functionCallbacks(List.of(FunctionCallback.builder()
|
||||
.function(TOOL_FUNCTION_NAME, new MockWeatherService())
|
||||
.toolCallbacks(List.of(FunctionToolCallback.builder(TOOL_FUNCTION_NAME, new MockWeatherService())
|
||||
.description("Get the weather in location")
|
||||
.inputType(MockWeatherService.Request.class)
|
||||
.build()))
|
||||
.build()),
|
||||
null);
|
||||
.build()));
|
||||
|
||||
assertThat(client.getFunctionCallbackRegister()).hasSize(1);
|
||||
assertThat(client.getFunctionCallbackRegister()).containsKeys(TOOL_FUNCTION_NAME);
|
||||
var request = client.createGeminiRequest(requestPrompt);
|
||||
|
||||
List<ToolDefinition> toolDefinitions = toolCallingManager
|
||||
.resolveToolDefinitions((ToolCallingChatOptions) requestPrompt.getOptions());
|
||||
|
||||
assertThat(toolDefinitions).hasSize(1);
|
||||
assertThat(toolDefinitions.get(0).name()).isSameAs(TOOL_FUNCTION_NAME);
|
||||
|
||||
assertThat(request.contents()).hasSize(1);
|
||||
assertThat(request.model().getSystemInstruction()).isNotPresent();
|
||||
@@ -140,33 +158,44 @@ public class CreateGeminiRequestTests {
|
||||
|
||||
final String TOOL_FUNCTION_NAME = "CurrentWeather";
|
||||
|
||||
var client = new VertexAiGeminiChatModel(this.vertexAI,
|
||||
VertexAiGeminiChatOptions.builder()
|
||||
.model("DEFAULT_MODEL")
|
||||
.functionCallbacks(List.of(FunctionCallback.builder()
|
||||
.function(TOOL_FUNCTION_NAME, new MockWeatherService())
|
||||
.description("Get the weather in location")
|
||||
.inputType(MockWeatherService.Request.class)
|
||||
.build()))
|
||||
.build());
|
||||
var toolCallingManager = ToolCallingManager.builder().build();
|
||||
|
||||
var request = client.createGeminiRequest(new Prompt("Test message content"), null);
|
||||
var client = VertexAiGeminiChatModel.builder()
|
||||
.vertexAI(this.vertexAI)
|
||||
.toolCallingManager(toolCallingManager)
|
||||
.defaultOptions(VertexAiGeminiChatOptions.builder()
|
||||
.model("DEFAULT_MODEL")
|
||||
.functionCallbacks(List.of(FunctionCallback.builder()
|
||||
.function(TOOL_FUNCTION_NAME, new MockWeatherService())
|
||||
.description("Get the weather in location")
|
||||
.inputType(MockWeatherService.Request.class)
|
||||
.build()))
|
||||
.build())
|
||||
.build();
|
||||
|
||||
assertThat(client.getFunctionCallbackRegister()).hasSize(1);
|
||||
assertThat(client.getFunctionCallbackRegister()).containsKeys(TOOL_FUNCTION_NAME);
|
||||
assertThat(client.getFunctionCallbackRegister().get(TOOL_FUNCTION_NAME).getDescription())
|
||||
.isEqualTo("Get the weather in location");
|
||||
var requestPrompt = client.buildRequestPrompt(new Prompt("Test message content"));
|
||||
|
||||
var request = client.createGeminiRequest(requestPrompt);
|
||||
|
||||
List<ToolDefinition> toolDefinitions = toolCallingManager
|
||||
.resolveToolDefinitions((ToolCallingChatOptions) requestPrompt.getOptions());
|
||||
|
||||
assertThat(toolDefinitions).hasSize(1);
|
||||
assertThat(toolDefinitions.get(0).name()).isSameAs(TOOL_FUNCTION_NAME);
|
||||
assertThat(toolDefinitions.get(0).description()).isEqualTo("Get the weather in location");
|
||||
|
||||
assertThat(request.contents()).hasSize(1);
|
||||
assertThat(request.model().getSystemInstruction()).isNotPresent();
|
||||
assertThat(request.model().getModelName()).isEqualTo("DEFAULT_MODEL");
|
||||
|
||||
assertThat(request.model().getTools()).as("Default Options callback functions are not automatically enabled!")
|
||||
.isNullOrEmpty();
|
||||
assertThat(request.model().getTools()).hasSize(1);
|
||||
|
||||
// Explicitly enable the function
|
||||
request = client.createGeminiRequest(new Prompt("Test message content",
|
||||
VertexAiGeminiChatOptions.builder().function(TOOL_FUNCTION_NAME).build()), null);
|
||||
|
||||
requestPrompt = client.buildRequestPrompt(new Prompt("Test message content",
|
||||
VertexAiGeminiChatOptions.builder().toolName(TOOL_FUNCTION_NAME).build()));
|
||||
|
||||
request = client.createGeminiRequest(requestPrompt);
|
||||
|
||||
assertThat(request.model().getTools()).hasSize(1);
|
||||
assertThat(request.model().getTools().get(0).getFunctionDeclarations(0).getName())
|
||||
@@ -174,43 +203,48 @@ public class CreateGeminiRequestTests {
|
||||
.isEqualTo(TOOL_FUNCTION_NAME);
|
||||
|
||||
// Override the default options function with one from the prompt
|
||||
request = client.createGeminiRequest(new Prompt("Test message content",
|
||||
requestPrompt = client.buildRequestPrompt(new Prompt("Test message content",
|
||||
VertexAiGeminiChatOptions.builder()
|
||||
.functionCallbacks(List.of(FunctionCallback.builder()
|
||||
.function(TOOL_FUNCTION_NAME, new MockWeatherService())
|
||||
.description("Overridden function description")
|
||||
.inputType(MockWeatherService.Request.class)
|
||||
.build()))
|
||||
.build()),
|
||||
null);
|
||||
.build()));
|
||||
request = client.createGeminiRequest(requestPrompt);
|
||||
|
||||
assertThat(request.model().getTools()).hasSize(1);
|
||||
assertThat(request.model().getTools().get(0).getFunctionDeclarations(0).getName())
|
||||
.as("Explicitly enabled function")
|
||||
.isEqualTo(TOOL_FUNCTION_NAME);
|
||||
|
||||
assertThat(client.getFunctionCallbackRegister()).hasSize(1);
|
||||
assertThat(client.getFunctionCallbackRegister()).containsKeys(TOOL_FUNCTION_NAME);
|
||||
assertThat(client.getFunctionCallbackRegister().get(TOOL_FUNCTION_NAME).getDescription())
|
||||
.isEqualTo("Overridden function description");
|
||||
toolDefinitions = toolCallingManager
|
||||
.resolveToolDefinitions((ToolCallingChatOptions) requestPrompt.getOptions());
|
||||
|
||||
assertThat(toolDefinitions).hasSize(1);
|
||||
assertThat(toolDefinitions.get(0).name()).isSameAs(TOOL_FUNCTION_NAME);
|
||||
assertThat(toolDefinitions.get(0).description()).isEqualTo("Overridden function description");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void createRequestWithGenerationConfigOptions() {
|
||||
|
||||
var client = new VertexAiGeminiChatModel(this.vertexAI,
|
||||
VertexAiGeminiChatOptions.builder()
|
||||
.model("DEFAULT_MODEL")
|
||||
.temperature(66.6)
|
||||
.maxOutputTokens(100)
|
||||
.topK(10)
|
||||
.topP(5.0)
|
||||
.stopSequences(List.of("stop1", "stop2"))
|
||||
.candidateCount(1)
|
||||
.responseMimeType("application/json")
|
||||
.build());
|
||||
var client = VertexAiGeminiChatModel.builder()
|
||||
.vertexAI(this.vertexAI)
|
||||
.defaultOptions(VertexAiGeminiChatOptions.builder()
|
||||
.model("DEFAULT_MODEL")
|
||||
.temperature(66.6)
|
||||
.maxOutputTokens(100)
|
||||
.topK(10)
|
||||
.topP(5.0)
|
||||
.stopSequences(List.of("stop1", "stop2"))
|
||||
.candidateCount(1)
|
||||
.responseMimeType("application/json")
|
||||
.build())
|
||||
.build();
|
||||
|
||||
GeminiRequest request = client.createGeminiRequest(new Prompt("Test message content"), null);
|
||||
GeminiRequest request = client
|
||||
.createGeminiRequest(client.buildRequestPrompt(new Prompt("Test message content")));
|
||||
|
||||
assertThat(request.contents()).hasSize(1);
|
||||
|
||||
|
||||
@@ -39,7 +39,6 @@ import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.SpringBootConfiguration;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.retry.support.RetryTemplate;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@@ -66,7 +65,7 @@ public class VertexAiChatModelObservationIT {
|
||||
void observationForChatOperation() {
|
||||
|
||||
var options = VertexAiGeminiChatOptions.builder()
|
||||
.model(VertexAiGeminiChatModel.ChatModel.GEMINI_1_5_PRO.getValue())
|
||||
.model(VertexAiGeminiChatModel.ChatModel.GEMINI_2_0_FLASH.getValue())
|
||||
.temperature(0.7)
|
||||
.stopSequences(List.of("this-is-the-end"))
|
||||
.maxOutputTokens(2048)
|
||||
@@ -88,7 +87,7 @@ public class VertexAiChatModelObservationIT {
|
||||
void observationForStreamingOperation() {
|
||||
|
||||
var options = VertexAiGeminiChatOptions.builder()
|
||||
.model(VertexAiGeminiChatModel.ChatModel.GEMINI_1_5_PRO.getValue())
|
||||
.model(VertexAiGeminiChatModel.ChatModel.GEMINI_2_0_FLASH.getValue())
|
||||
.temperature(0.7)
|
||||
.stopSequences(List.of("this-is-the-end"))
|
||||
.maxOutputTokens(2048)
|
||||
@@ -128,7 +127,7 @@ public class VertexAiChatModelObservationIT {
|
||||
AiProvider.VERTEX_AI.value())
|
||||
.hasLowCardinalityKeyValue(
|
||||
ChatModelObservationDocumentation.LowCardinalityKeyNames.REQUEST_MODEL.asString(),
|
||||
VertexAiGeminiChatModel.ChatModel.GEMINI_1_5_PRO.getValue())
|
||||
VertexAiGeminiChatModel.ChatModel.GEMINI_2_0_FLASH.getValue())
|
||||
.hasHighCardinalityKeyValue(
|
||||
ChatModelObservationDocumentation.HighCardinalityKeyNames.REQUEST_MAX_TOKENS.asString(), "2048")
|
||||
.hasHighCardinalityKeyValue(
|
||||
@@ -177,9 +176,14 @@ public class VertexAiChatModelObservationIT {
|
||||
@Bean
|
||||
public VertexAiGeminiChatModel vertexAiEmbedding(VertexAI vertexAi,
|
||||
TestObservationRegistry observationRegistry) {
|
||||
return new VertexAiGeminiChatModel(vertexAi,
|
||||
VertexAiGeminiChatOptions.builder().model(VertexAiGeminiChatModel.ChatModel.GEMINI_1_5_PRO).build(),
|
||||
null, List.of(), RetryTemplate.defaultInstance(), observationRegistry);
|
||||
|
||||
return VertexAiGeminiChatModel.builder()
|
||||
.vertexAI(vertexAi)
|
||||
.observationRegistry(observationRegistry)
|
||||
.defaultOptions(VertexAiGeminiChatOptions.builder()
|
||||
.model(VertexAiGeminiChatModel.ChatModel.GEMINI_2_0_FLASH)
|
||||
.build())
|
||||
.build();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import java.util.stream.Stream;
|
||||
import com.google.cloud.vertexai.Transport;
|
||||
import com.google.cloud.vertexai.VertexAI;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.junit.jupiter.api.Disabled;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
|
||||
|
||||
@@ -41,6 +42,7 @@ import org.springframework.ai.converter.BeanOutputConverter;
|
||||
import org.springframework.ai.converter.ListOutputConverter;
|
||||
import org.springframework.ai.converter.MapOutputConverter;
|
||||
import org.springframework.ai.model.Media;
|
||||
import org.springframework.ai.vertexai.gemini.VertexAiGeminiChatModel.ChatModel;
|
||||
import org.springframework.ai.vertexai.gemini.common.VertexAiGeminiSafetySetting;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
@@ -88,19 +90,24 @@ class VertexAiGeminiChatModelIT {
|
||||
|
||||
@Test
|
||||
void googleSearchTool() {
|
||||
Prompt prompt = createPrompt(VertexAiGeminiChatOptions.builder().googleSearchRetrieval(true).build());
|
||||
Prompt prompt = createPrompt(VertexAiGeminiChatOptions.builder()
|
||||
.model(ChatModel.GEMINI_1_5_PRO) // Only the pro model supports the google
|
||||
// search tool
|
||||
.googleSearchRetrieval(true)
|
||||
.build());
|
||||
ChatResponse response = this.chatModel.call(prompt);
|
||||
assertThat(response.getResult().getOutput().getText()).containsAnyOf("Blackbeard", "Bartholomew");
|
||||
}
|
||||
|
||||
@Test
|
||||
@Disabled
|
||||
void testSafetySettings() {
|
||||
List<VertexAiGeminiSafetySetting> safetySettings = List.of(new VertexAiGeminiSafetySetting.Builder()
|
||||
.withCategory(VertexAiGeminiSafetySetting.HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT)
|
||||
.withThreshold(VertexAiGeminiSafetySetting.HarmBlockThreshold.BLOCK_LOW_AND_ABOVE)
|
||||
.build());
|
||||
Prompt prompt = new Prompt("What are common digital attack vectors?",
|
||||
VertexAiGeminiChatOptions.builder().safetySettings(safetySettings).build());
|
||||
Prompt prompt = new Prompt("How to make cocktail Molotov bomb at home?",
|
||||
VertexAiGeminiChatOptions.builder().model(ChatModel.GEMINI_PRO).safetySettings(safetySettings).build());
|
||||
ChatResponse response = this.chatModel.call(prompt);
|
||||
assertThat(response.getResult().getMetadata().getFinishReason()).isEqualTo("SAFETY");
|
||||
}
|
||||
@@ -235,7 +242,8 @@ class VertexAiGeminiChatModelIT {
|
||||
|
||||
// Response should contain something like:
|
||||
// I see a bunch of bananas in a golden basket. The bananas are ripe and yellow.
|
||||
// There are also some red apples in the basket. The basket is sitting on a table.
|
||||
// There are also some red apples in the basket. The basket is sitting on a
|
||||
// table.
|
||||
// The background is a blurred light blue color.'
|
||||
assertThat(response.getResult().getOutput().getText()).satisfies(content -> {
|
||||
long count = Stream.of("bananas", "apple", "basket").filter(content::contains).count();
|
||||
@@ -293,10 +301,12 @@ class VertexAiGeminiChatModelIT {
|
||||
|
||||
@Bean
|
||||
public VertexAiGeminiChatModel vertexAiEmbedding(VertexAI vertexAi) {
|
||||
return new VertexAiGeminiChatModel(vertexAi,
|
||||
VertexAiGeminiChatOptions.builder()
|
||||
.model(VertexAiGeminiChatModel.ChatModel.GEMINI_1_5_PRO)
|
||||
.build());
|
||||
return VertexAiGeminiChatModel.builder()
|
||||
.vertexAI(vertexAi)
|
||||
.defaultOptions(VertexAiGeminiChatOptions.builder()
|
||||
.model(VertexAiGeminiChatModel.ChatModel.GEMINI_2_0_FLASH)
|
||||
.build())
|
||||
.build();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ public class VertexAiGeminiRetryTests {
|
||||
VertexAiGeminiChatOptions.builder()
|
||||
.temperature(0.7)
|
||||
.topP(1.0)
|
||||
.model(VertexAiGeminiChatModel.ChatModel.GEMINI_PRO.getValue())
|
||||
.model(VertexAiGeminiChatModel.ChatModel.GEMINI_2_0_FLASH.getValue())
|
||||
.build(),
|
||||
null, Collections.emptyList(), this.retryTemplate);
|
||||
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
/*
|
||||
* Copyright 2025-2025 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.schema;
|
||||
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
/**
|
||||
* Tests for {@link JsonSchemaConverter}.
|
||||
*
|
||||
* @author Christian Tzolov
|
||||
*/
|
||||
class JsonSchemaConverterTests {
|
||||
|
||||
@Test
|
||||
void fromJsonShouldParseValidJson() {
|
||||
String json = "{\"type\":\"object\",\"properties\":{\"name\":{\"type\":\"string\"}}}";
|
||||
ObjectNode result = JsonSchemaConverter.fromJson(json);
|
||||
|
||||
assertThat(result.get("type").asText()).isEqualTo("object");
|
||||
assertThat(result.get("properties").get("name").get("type").asText()).isEqualTo("string");
|
||||
}
|
||||
|
||||
@Test
|
||||
void fromJsonShouldThrowOnInvalidJson() {
|
||||
String invalidJson = "{invalid:json}";
|
||||
assertThatThrownBy(() -> JsonSchemaConverter.fromJson(invalidJson)).isInstanceOf(RuntimeException.class)
|
||||
.hasMessageContaining("Failed to parse JSON");
|
||||
}
|
||||
|
||||
@Test
|
||||
void convertToOpenApiSchemaShouldThrowOnNullInput() {
|
||||
assertThatThrownBy(() -> JsonSchemaConverter.convertToOpenApiSchema(null))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("JSON Schema node must not be null");
|
||||
}
|
||||
|
||||
@Nested
|
||||
class SchemaConversionTests {
|
||||
|
||||
@Test
|
||||
void shouldConvertBasicSchema() {
|
||||
String json = """
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "The name property"
|
||||
}
|
||||
},
|
||||
"required": ["name"]
|
||||
}
|
||||
""";
|
||||
|
||||
ObjectNode result = JsonSchemaConverter.convertToOpenApiSchema(JsonSchemaConverter.fromJson(json));
|
||||
|
||||
assertThat(result.get("openapi").asText()).isEqualTo("3.0.0");
|
||||
assertThat(result.get("type").asText()).isEqualTo("object");
|
||||
assertThat(result.get("properties").get("name").get("type").asText()).isEqualTo("string");
|
||||
assertThat(result.get("properties").get("name").get("description").asText()).isEqualTo("The name property");
|
||||
assertThat(result.get("required").get(0).asText()).isEqualTo("name");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleArrayTypes() {
|
||||
String json = """
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
ObjectNode result = JsonSchemaConverter.convertToOpenApiSchema(JsonSchemaConverter.fromJson(json));
|
||||
|
||||
assertThat(result.get("properties").get("tags").get("type").asText()).isEqualTo("array");
|
||||
assertThat(result.get("properties").get("tags").get("items").get("type").asText()).isEqualTo("string");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleAdditionalProperties() {
|
||||
String json = """
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
ObjectNode result = JsonSchemaConverter.convertToOpenApiSchema(JsonSchemaConverter.fromJson(json));
|
||||
|
||||
assertThat(result.get("additionalProperties").get("type").asText()).isEqualTo("string");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleCombiningSchemas() {
|
||||
String json = """
|
||||
{
|
||||
"type": "object",
|
||||
"allOf": [
|
||||
{"type": "object", "properties": {"name": {"type": "string"}}},
|
||||
{"type": "object", "properties": {"age": {"type": "integer"}}}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
ObjectNode result = JsonSchemaConverter.convertToOpenApiSchema(JsonSchemaConverter.fromJson(json));
|
||||
|
||||
assertThat(result.get("allOf")).isNotNull();
|
||||
assertThat(result.get("allOf").isArray()).isTrue();
|
||||
assertThat(result.get("allOf").size()).isEqualTo(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldCopyCommonProperties() {
|
||||
String json = """
|
||||
{
|
||||
"type": "string",
|
||||
"format": "email",
|
||||
"description": "Email address",
|
||||
"minLength": 5,
|
||||
"maxLength": 100,
|
||||
"pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\\\.[a-zA-Z]{2,}$",
|
||||
"example": "user@example.com",
|
||||
"deprecated": false
|
||||
}
|
||||
""";
|
||||
|
||||
ObjectNode result = JsonSchemaConverter.convertToOpenApiSchema(JsonSchemaConverter.fromJson(json));
|
||||
|
||||
assertThat(result.get("type").asText()).isEqualTo("string");
|
||||
assertThat(result.get("format").asText()).isEqualTo("email");
|
||||
assertThat(result.get("description").asText()).isEqualTo("Email address");
|
||||
assertThat(result.get("minLength").asInt()).isEqualTo(5);
|
||||
assertThat(result.get("maxLength").asInt()).isEqualTo(100);
|
||||
assertThat(result.get("pattern").asText()).isEqualTo("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$");
|
||||
assertThat(result.get("example").asText()).isEqualTo("user@example.com");
|
||||
assertThat(result.get("deprecated").asBoolean()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleNestedObjects() {
|
||||
String json = """
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"user": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"address": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"street": {"type": "string"},
|
||||
"city": {"type": "string"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
ObjectNode result = JsonSchemaConverter.convertToOpenApiSchema(JsonSchemaConverter.fromJson(json));
|
||||
|
||||
assertThat(result.get("properties")
|
||||
.get("user")
|
||||
.get("properties")
|
||||
.get("address")
|
||||
.get("properties")
|
||||
.get("street")
|
||||
.get("type")
|
||||
.asText()).isEqualTo("string");
|
||||
assertThat(result.get("properties")
|
||||
.get("user")
|
||||
.get("properties")
|
||||
.get("address")
|
||||
.get("properties")
|
||||
.get("city")
|
||||
.get("type")
|
||||
.asText()).isEqualTo("string");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.ai.vertexai.gemini.function;
|
||||
package org.springframework.ai.vertexai.gemini.tool;
|
||||
|
||||
import java.util.function.Function;
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.ai.vertexai.gemini.function;
|
||||
package org.springframework.ai.vertexai.gemini.tool;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
@@ -49,6 +49,7 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
@SpringBootTest
|
||||
@EnabledIfEnvironmentVariable(named = "VERTEX_AI_GEMINI_PROJECT_ID", matches = ".*")
|
||||
@EnabledIfEnvironmentVariable(named = "VERTEX_AI_GEMINI_LOCATION", matches = ".*")
|
||||
@Deprecated
|
||||
public class VertexAiGeminiChatModelFunctionCallingIT {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(VertexAiGeminiChatModelFunctionCallingIT.class);
|
||||
@@ -106,7 +107,7 @@ public class VertexAiGeminiChatModelFunctionCallingIT {
|
||||
List<Message> messages = new ArrayList<>(List.of(userMessage));
|
||||
|
||||
var promptOptions = VertexAiGeminiChatOptions.builder()
|
||||
.model(VertexAiGeminiChatModel.ChatModel.GEMINI_1_5_FLASH)
|
||||
.model(VertexAiGeminiChatModel.ChatModel.GEMINI_2_0_FLASH)
|
||||
.functionCallbacks(List.of(
|
||||
FunctionToolCallback.builder("get_current_weather", new MockWeatherService())
|
||||
.inputSchema(JsonSchemaGenerator.generateForType(MockWeatherService.Request.class,
|
||||
@@ -138,6 +139,43 @@ public class VertexAiGeminiChatModelFunctionCallingIT {
|
||||
|
||||
}
|
||||
|
||||
@Test
|
||||
public void functionCallTestInferredOpenApiSchema2() {
|
||||
|
||||
UserMessage userMessage = new UserMessage(
|
||||
"What's the weather like in San Francisco, Paris and in Tokyo? Return the temperature in Celsius.");
|
||||
|
||||
List<Message> messages = new ArrayList<>(List.of(userMessage));
|
||||
|
||||
var promptOptions = VertexAiGeminiChatOptions.builder()
|
||||
.model(VertexAiGeminiChatModel.ChatModel.GEMINI_2_0_FLASH)
|
||||
.functionCallbacks(List.of(
|
||||
FunctionToolCallback.builder("get_current_weather", new MockWeatherService())
|
||||
.description("Get the current weather in a given location.")
|
||||
.inputType(MockWeatherService.Request.class)
|
||||
.build(),
|
||||
FunctionToolCallback.builder("get_payment_status", new PaymentStatus())
|
||||
.description(
|
||||
"Retrieves the payment status for transaction. For example what is the payment status for transaction 700?")
|
||||
.inputType(PaymentInfoRequest.class)
|
||||
.build()))
|
||||
.build();
|
||||
|
||||
ChatResponse response = this.chatModel.call(new Prompt(messages, promptOptions));
|
||||
|
||||
logger.info("Response: {}", response);
|
||||
|
||||
assertThat(response.getResult().getOutput().getText()).contains("30", "10", "15");
|
||||
|
||||
ChatResponse response2 = this.chatModel
|
||||
.call(new Prompt("What is the payment status for transaction 696?", promptOptions));
|
||||
|
||||
logger.info("Response: {}", response2);
|
||||
|
||||
assertThat(response2.getResult().getOutput().getText()).containsIgnoringCase("transaction 696 is PAYED");
|
||||
|
||||
}
|
||||
|
||||
@Test
|
||||
public void functionCallTestInferredOpenApiSchemaStream() {
|
||||
|
||||
@@ -147,7 +185,7 @@ public class VertexAiGeminiChatModelFunctionCallingIT {
|
||||
List<Message> messages = new ArrayList<>(List.of(userMessage));
|
||||
|
||||
var promptOptions = VertexAiGeminiChatOptions.builder()
|
||||
.model(VertexAiGeminiChatModel.ChatModel.GEMINI_1_5_FLASH)
|
||||
.model(VertexAiGeminiChatModel.ChatModel.GEMINI_2_0_FLASH)
|
||||
.functionCallbacks(List.of(FunctionToolCallback.builder("getCurrentWeather", new MockWeatherService())
|
||||
.inputSchema(JsonSchemaGenerator.generateForType(MockWeatherService.Request.class,
|
||||
JsonSchemaGenerator.SchemaOption.UPPER_CASE_TYPE_VALUES))
|
||||
@@ -207,7 +245,7 @@ public class VertexAiGeminiChatModelFunctionCallingIT {
|
||||
public VertexAiGeminiChatModel vertexAiEmbedding(VertexAI vertexAi) {
|
||||
return new VertexAiGeminiChatModel(vertexAi,
|
||||
VertexAiGeminiChatOptions.builder()
|
||||
.model(VertexAiGeminiChatModel.ChatModel.GEMINI_1_5_PRO)
|
||||
.model(VertexAiGeminiChatModel.ChatModel.GEMINI_2_0_FLASH)
|
||||
.temperature(0.9)
|
||||
.build());
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
/*
|
||||
* Copyright 2023-2025 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.tool;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
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.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.messages.AssistantMessage;
|
||||
import org.springframework.ai.chat.messages.Message;
|
||||
import org.springframework.ai.chat.messages.UserMessage;
|
||||
import org.springframework.ai.chat.model.ChatResponse;
|
||||
import org.springframework.ai.chat.model.Generation;
|
||||
import org.springframework.ai.chat.prompt.Prompt;
|
||||
import org.springframework.ai.tool.function.FunctionToolCallback;
|
||||
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.annotation.Bean;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@SpringBootTest
|
||||
@EnabledIfEnvironmentVariable(named = "VERTEX_AI_GEMINI_PROJECT_ID", matches = ".*")
|
||||
@EnabledIfEnvironmentVariable(named = "VERTEX_AI_GEMINI_LOCATION", matches = ".*")
|
||||
public class VertexAiGeminiChatModelToolCallingIT {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(VertexAiGeminiChatModelToolCallingIT.class);
|
||||
|
||||
@Autowired
|
||||
private VertexAiGeminiChatModel chatModel;
|
||||
|
||||
@Test
|
||||
public void functionCallExplicitOpenApiSchema() {
|
||||
|
||||
UserMessage userMessage = new UserMessage(
|
||||
"What's the weather like in San Francisco, Paris and in Tokyo? Return the temperature in Celsius.");
|
||||
|
||||
List<Message> messages = new ArrayList<>(List.of(userMessage));
|
||||
|
||||
String openApiSchema = """
|
||||
{
|
||||
"type": "OBJECT",
|
||||
"properties": {
|
||||
"location": {
|
||||
"type": "STRING",
|
||||
"description": "The city and state e.g. San Francisco, CA"
|
||||
},
|
||||
"unit" : {
|
||||
"type" : "STRING",
|
||||
"enum" : [ "C", "F" ],
|
||||
"description" : "Temperature unit"
|
||||
}
|
||||
},
|
||||
"required": ["location", "unit"]
|
||||
}
|
||||
""";
|
||||
|
||||
var promptOptions = VertexAiGeminiChatOptions.builder()
|
||||
.toolCallbacks(List.of(FunctionToolCallback.builder("get_current_weather", new MockWeatherService())
|
||||
.description("Get the current weather in a given location")
|
||||
.inputSchema(openApiSchema)
|
||||
.inputType(MockWeatherService.Request.class)
|
||||
.build()))
|
||||
.build();
|
||||
|
||||
ChatResponse response = this.chatModel.call(new Prompt(messages, promptOptions));
|
||||
|
||||
logger.info("Response: {}", response);
|
||||
|
||||
assertThat(response.getResult().getOutput().getText()).contains("30", "10", "15");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void functionCallTestInferredOpenApiSchema() {
|
||||
|
||||
UserMessage userMessage = new UserMessage(
|
||||
"What's the weather like in San Francisco, Paris and in Tokyo? Return the temperature in Celsius.");
|
||||
|
||||
List<Message> messages = new ArrayList<>(List.of(userMessage));
|
||||
|
||||
var promptOptions = VertexAiGeminiChatOptions.builder()
|
||||
.model(VertexAiGeminiChatModel.ChatModel.GEMINI_2_0_FLASH)
|
||||
.toolCallbacks(List.of(
|
||||
FunctionToolCallback.builder("get_current_weather", new MockWeatherService())
|
||||
.description("Get the current weather in a given location.")
|
||||
.inputType(MockWeatherService.Request.class)
|
||||
.build(),
|
||||
FunctionToolCallback.builder("get_payment_status", new PaymentStatus())
|
||||
.description(
|
||||
"Retrieves the payment status for transaction. For example what is the payment status for transaction 700?")
|
||||
.inputType(PaymentInfoRequest.class)
|
||||
.build()))
|
||||
.build();
|
||||
|
||||
ChatResponse response = this.chatModel.call(new Prompt(messages, promptOptions));
|
||||
|
||||
logger.info("Response: {}", response);
|
||||
|
||||
assertThat(response.getResult().getOutput().getText()).contains("30", "10", "15");
|
||||
|
||||
ChatResponse response2 = this.chatModel
|
||||
.call(new Prompt("What is the payment status for transaction 696?", promptOptions));
|
||||
|
||||
logger.info("Response: {}", response2);
|
||||
|
||||
assertThat(response2.getResult().getOutput().getText()).containsIgnoringCase("transaction 696 is PAYED");
|
||||
|
||||
}
|
||||
|
||||
@Test
|
||||
public void functionCallTestInferredOpenApiSchemaStream() {
|
||||
|
||||
UserMessage userMessage = new UserMessage(
|
||||
"What's the weather like in San Francisco, Paris and in Tokyo? Return the temperature in Celsius.");
|
||||
|
||||
List<Message> messages = new ArrayList<>(List.of(userMessage));
|
||||
|
||||
var promptOptions = VertexAiGeminiChatOptions.builder()
|
||||
.model(VertexAiGeminiChatModel.ChatModel.GEMINI_2_0_FLASH)
|
||||
.toolCallbacks(List.of(FunctionToolCallback.builder("getCurrentWeather", new MockWeatherService())
|
||||
.description("Get the current weather in a given location")
|
||||
.inputType(MockWeatherService.Request.class)
|
||||
.build()))
|
||||
.build();
|
||||
|
||||
Flux<ChatResponse> response = this.chatModel.stream(new Prompt(messages, promptOptions));
|
||||
|
||||
String responseString = response.collectList()
|
||||
.block()
|
||||
.stream()
|
||||
.map(ChatResponse::getResults)
|
||||
.flatMap(List::stream)
|
||||
.map(Generation::getOutput)
|
||||
.map(AssistantMessage::getText)
|
||||
.collect(Collectors.joining());
|
||||
|
||||
logger.info("Response: {}", responseString);
|
||||
|
||||
assertThat(responseString).contains("30", "10", "15");
|
||||
|
||||
}
|
||||
|
||||
public record PaymentInfoRequest(String id) {
|
||||
|
||||
}
|
||||
|
||||
public record TransactionStatus(String status) {
|
||||
|
||||
}
|
||||
|
||||
public static class PaymentStatus implements Function<PaymentInfoRequest, TransactionStatus> {
|
||||
|
||||
@Override
|
||||
public TransactionStatus apply(PaymentInfoRequest paymentInfoRequest) {
|
||||
return new TransactionStatus("Transaction " + paymentInfoRequest.id() + " is PAYED");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@SpringBootConfiguration
|
||||
public static class TestConfiguration {
|
||||
|
||||
@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)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public VertexAiGeminiChatModel vertexAiEmbedding(VertexAI vertexAi) {
|
||||
return VertexAiGeminiChatModel.builder()
|
||||
.vertexAI(vertexAi)
|
||||
.defaultOptions(VertexAiGeminiChatOptions.builder()
|
||||
.model(VertexAiGeminiChatModel.ChatModel.GEMINI_2_0_FLASH)
|
||||
.temperature(0.9)
|
||||
.build())
|
||||
.build();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.ai.vertexai.gemini.function;
|
||||
package org.springframework.ai.vertexai.gemini.tool;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -55,9 +55,10 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
@SpringBootTest
|
||||
@EnabledIfEnvironmentVariable(named = "VERTEX_AI_GEMINI_PROJECT_ID", matches = ".*")
|
||||
@EnabledIfEnvironmentVariable(named = "VERTEX_AI_GEMINI_LOCATION", matches = ".*")
|
||||
public class VertexAiGeminiPaymentTransactionIT {
|
||||
@Deprecated
|
||||
public class VertexAiGeminiPaymentTransactionDeprecatedIT {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(VertexAiGeminiPaymentTransactionIT.class);
|
||||
private static final Logger logger = LoggerFactory.getLogger(VertexAiGeminiPaymentTransactionDeprecatedIT.class);
|
||||
|
||||
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"));
|
||||
@@ -0,0 +1,247 @@
|
||||
/*
|
||||
* 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.vertexai.gemini.tool;
|
||||
|
||||
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 io.micrometer.observation.ObservationRegistry;
|
||||
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.ChatClient;
|
||||
import org.springframework.ai.chat.client.advisor.api.AdvisedRequest;
|
||||
import org.springframework.ai.chat.client.advisor.api.AdvisedResponse;
|
||||
import org.springframework.ai.chat.client.advisor.api.CallAroundAdvisor;
|
||||
import org.springframework.ai.chat.client.advisor.api.CallAroundAdvisorChain;
|
||||
import org.springframework.ai.model.function.FunctionCallback;
|
||||
import org.springframework.ai.model.tool.ToolCallingManager;
|
||||
import org.springframework.ai.tool.execution.DefaultToolExecutionExceptionProcessor;
|
||||
import org.springframework.ai.tool.resolution.DelegatingToolCallbackResolver;
|
||||
import org.springframework.ai.tool.resolution.SpringBeanToolCallbackResolver;
|
||||
import org.springframework.ai.tool.resolution.StaticToolCallbackResolver;
|
||||
import org.springframework.ai.tool.resolution.ToolCallbackResolver;
|
||||
import org.springframework.ai.vertexai.gemini.VertexAiGeminiChatModel;
|
||||
import org.springframework.ai.vertexai.gemini.VertexAiGeminiChatOptions;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.SpringBootConfiguration;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Description;
|
||||
import org.springframework.context.support.GenericApplicationContext;
|
||||
|
||||
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 VertexAiGeminiPaymentTransactionIT {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(VertexAiGeminiPaymentTransactionIT.class);
|
||||
|
||||
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"));
|
||||
|
||||
@Autowired
|
||||
ChatClient chatClient;
|
||||
|
||||
@Test
|
||||
public void paymentStatuses() {
|
||||
// @formatter:off
|
||||
String content = this.chatClient.prompt()
|
||||
.advisors(new LoggingAdvisor())
|
||||
.tools("paymentStatus")
|
||||
.user("""
|
||||
What is the status of my payment transactions 001, 002 and 003?
|
||||
If requred invoke the function per transaction.
|
||||
""").call().content();
|
||||
// @formatter:on
|
||||
logger.info("" + content);
|
||||
|
||||
assertThat(content).contains("001", "002", "003");
|
||||
assertThat(content).contains("pending", "approved", "rejected");
|
||||
}
|
||||
|
||||
@RepeatedTest(5)
|
||||
public void streamingPaymentStatuses() {
|
||||
|
||||
Flux<String> streamContent = this.chatClient.prompt()
|
||||
.advisors(new LoggingAdvisor())
|
||||
.tools("paymentStatus")
|
||||
.user("""
|
||||
What is the status of my payment transactions 001, 002 and 003?
|
||||
If requred invoke the function per transaction.
|
||||
""")
|
||||
.stream()
|
||||
.content();
|
||||
|
||||
String content = streamContent.collectList().block().stream().collect(Collectors.joining());
|
||||
|
||||
logger.info(content);
|
||||
|
||||
assertThat(content).contains("001", "002", "003");
|
||||
assertThat(content).contains("pending", "approved", "rejected");
|
||||
|
||||
// Quota rate
|
||||
try {
|
||||
Thread.sleep(1000);
|
||||
}
|
||||
catch (InterruptedException e) {
|
||||
}
|
||||
}
|
||||
|
||||
record TransactionStatusResponse(String id, String status) {
|
||||
|
||||
}
|
||||
|
||||
private static class LoggingAdvisor implements CallAroundAdvisor {
|
||||
|
||||
private final Logger logger = LoggerFactory.getLogger(LoggingAdvisor.class);
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return this.getClass().getSimpleName();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getOrder() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AdvisedResponse aroundCall(AdvisedRequest advisedRequest, CallAroundAdvisorChain chain) {
|
||||
var response = chain.nextAroundCall(before(advisedRequest));
|
||||
observeAfter(response);
|
||||
return response;
|
||||
}
|
||||
|
||||
private AdvisedRequest before(AdvisedRequest request) {
|
||||
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;
|
||||
}
|
||||
|
||||
private void observeAfter(AdvisedResponse advisedResponse) {
|
||||
logger.info("Response: " + advisedResponse.response());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
record Transaction(String id) {
|
||||
}
|
||||
|
||||
record Status(String name) {
|
||||
}
|
||||
|
||||
record Transactions(List<Transaction> transactions) {
|
||||
}
|
||||
|
||||
record Statuses(List<Status> statuses) {
|
||||
}
|
||||
|
||||
@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, ToolCallingManager toolCallingManager) {
|
||||
|
||||
return VertexAiGeminiChatModel.builder()
|
||||
.vertexAI(vertexAi)
|
||||
.toolCallingManager(toolCallingManager)
|
||||
.defaultOptions(VertexAiGeminiChatOptions.builder()
|
||||
.model(VertexAiGeminiChatModel.ChatModel.GEMINI_2_0_FLASH)
|
||||
.temperature(0.1)
|
||||
.build())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
ToolCallingManager toolCallingManager(GenericApplicationContext applicationContext,
|
||||
List<FunctionCallback> toolCallbacks, ObjectProvider<ObservationRegistry> observationRegistry) {
|
||||
|
||||
var staticToolCallbackResolver = new StaticToolCallbackResolver(toolCallbacks);
|
||||
var springBeanToolCallbackResolver = SpringBeanToolCallbackResolver.builder()
|
||||
.applicationContext(applicationContext)
|
||||
.build();
|
||||
|
||||
ToolCallbackResolver toolCallbackResolver = new DelegatingToolCallbackResolver(
|
||||
List.of(staticToolCallbackResolver, springBeanToolCallbackResolver));
|
||||
|
||||
return ToolCallingManager.builder()
|
||||
.observationRegistry(observationRegistry.getIfUnique(() -> ObservationRegistry.NOOP))
|
||||
.toolCallbackResolver(toolCallbackResolver)
|
||||
.toolExecutionExceptionProcessor(new DefaultToolExecutionExceptionProcessor(false))
|
||||
.build();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
/*
|
||||
* 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.vertexai.gemini.tool;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import com.google.cloud.vertexai.Transport;
|
||||
import com.google.cloud.vertexai.VertexAI;
|
||||
import io.micrometer.observation.ObservationRegistry;
|
||||
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.ChatClient;
|
||||
import org.springframework.ai.chat.client.advisor.api.AdvisedRequest;
|
||||
import org.springframework.ai.chat.client.advisor.api.AdvisedResponse;
|
||||
import org.springframework.ai.chat.client.advisor.api.CallAroundAdvisor;
|
||||
import org.springframework.ai.chat.client.advisor.api.CallAroundAdvisorChain;
|
||||
import org.springframework.ai.model.function.FunctionCallback;
|
||||
import org.springframework.ai.model.tool.ToolCallingManager;
|
||||
import org.springframework.ai.tool.ToolCallback;
|
||||
import org.springframework.ai.tool.ToolCallbacks;
|
||||
import org.springframework.ai.tool.annotation.Tool;
|
||||
import org.springframework.ai.tool.execution.DefaultToolExecutionExceptionProcessor;
|
||||
import org.springframework.ai.tool.resolution.DelegatingToolCallbackResolver;
|
||||
import org.springframework.ai.tool.resolution.SpringBeanToolCallbackResolver;
|
||||
import org.springframework.ai.tool.resolution.StaticToolCallbackResolver;
|
||||
import org.springframework.ai.tool.resolution.ToolCallbackResolver;
|
||||
import org.springframework.ai.vertexai.gemini.VertexAiGeminiChatModel;
|
||||
import org.springframework.ai.vertexai.gemini.VertexAiGeminiChatOptions;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.SpringBootConfiguration;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.support.GenericApplicationContext;
|
||||
|
||||
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 VertexAiGeminiPaymentTransactionMethodIT {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(VertexAiGeminiPaymentTransactionMethodIT.class);
|
||||
|
||||
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"));
|
||||
|
||||
@Autowired
|
||||
ChatClient chatClient;
|
||||
|
||||
@Test
|
||||
public void paymentStatuses() {
|
||||
|
||||
String content = this.chatClient.prompt().advisors(new LoggingAdvisor()).tools("paymentStatus").user("""
|
||||
What is the status of my payment transactions 001, 002 and 003?
|
||||
If requred invoke the function per transaction.
|
||||
""").call().content();
|
||||
logger.info("" + content);
|
||||
|
||||
assertThat(content).contains("001", "002", "003");
|
||||
assertThat(content).contains("pending", "approved", "rejected");
|
||||
}
|
||||
|
||||
@RepeatedTest(5)
|
||||
public void streamingPaymentStatuses() {
|
||||
|
||||
Flux<String> streamContent = this.chatClient.prompt()
|
||||
.advisors(new LoggingAdvisor())
|
||||
.tools("paymentStatus")
|
||||
.user("""
|
||||
What is the status of my payment transactions 001, 002 and 003?
|
||||
If requred invoke the function per transaction.
|
||||
""")
|
||||
.stream()
|
||||
.content();
|
||||
|
||||
String content = streamContent.collectList().block().stream().collect(Collectors.joining());
|
||||
|
||||
logger.info(content);
|
||||
|
||||
assertThat(content).contains("001", "002", "003");
|
||||
assertThat(content).contains("pending", "approved", "rejected");
|
||||
|
||||
// Quota rate
|
||||
try {
|
||||
Thread.sleep(1000);
|
||||
}
|
||||
catch (InterruptedException e) {
|
||||
}
|
||||
}
|
||||
|
||||
record TransactionStatusResponse(String id, String status) {
|
||||
|
||||
}
|
||||
|
||||
private static class LoggingAdvisor implements CallAroundAdvisor {
|
||||
|
||||
private final Logger logger = LoggerFactory.getLogger(LoggingAdvisor.class);
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return this.getClass().getSimpleName();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getOrder() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AdvisedResponse aroundCall(AdvisedRequest advisedRequest, CallAroundAdvisorChain chain) {
|
||||
var response = chain.nextAroundCall(before(advisedRequest));
|
||||
observeAfter(response);
|
||||
return response;
|
||||
}
|
||||
|
||||
private AdvisedRequest before(AdvisedRequest request) {
|
||||
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;
|
||||
}
|
||||
|
||||
private void observeAfter(AdvisedResponse advisedResponse) {
|
||||
logger.info("Response: " + advisedResponse.response());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
record Transaction(String id) {
|
||||
}
|
||||
|
||||
record Status(String name) {
|
||||
}
|
||||
|
||||
public static class PaymentService {
|
||||
|
||||
@Tool(description = "Get the status of a single payment transaction")
|
||||
public Status paymentStatus(Transaction transaction) {
|
||||
logger.info("Single Transaction: " + transaction);
|
||||
return DATASET.get(transaction);
|
||||
}
|
||||
|
||||
@Tool(description = "Get the list statuses of a list of payment transactions")
|
||||
public List<Status> statusespaymentStatuses(List<Transaction> transactions) {
|
||||
logger.info("Transactions: " + transactions);
|
||||
return transactions.stream().map(t -> DATASET.get(t)).toList();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@SpringBootConfiguration
|
||||
public static class TestConfiguration {
|
||||
|
||||
@Bean
|
||||
public List<ToolCallback> paymentServiceTools() {
|
||||
var tools = List.of(ToolCallbacks.from(new PaymentService()));
|
||||
return tools;
|
||||
}
|
||||
|
||||
@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, ToolCallingManager toolCallingManager) {
|
||||
|
||||
return VertexAiGeminiChatModel.builder()
|
||||
.vertexAI(vertexAi)
|
||||
.toolCallingManager(toolCallingManager)
|
||||
.defaultOptions(VertexAiGeminiChatOptions.builder()
|
||||
.model(VertexAiGeminiChatModel.ChatModel.GEMINI_2_0_FLASH)
|
||||
.temperature(0.1)
|
||||
.build())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
ToolCallingManager toolCallingManager(GenericApplicationContext applicationContext,
|
||||
List<ToolCallback> toolCallbacks, List<FunctionCallback> functionCallbacks,
|
||||
ObjectProvider<ObservationRegistry> observationRegistry) {
|
||||
|
||||
List<FunctionCallback> allFunctionCallbacks = new ArrayList(functionCallbacks);
|
||||
allFunctionCallbacks.addAll(toolCallbacks.stream().map(tc -> (FunctionCallback) tc).toList());
|
||||
|
||||
var staticToolCallbackResolver = new StaticToolCallbackResolver(allFunctionCallbacks);
|
||||
|
||||
var springBeanToolCallbackResolver = SpringBeanToolCallbackResolver.builder()
|
||||
.applicationContext(applicationContext)
|
||||
.build();
|
||||
|
||||
ToolCallbackResolver toolCallbackResolver = new DelegatingToolCallbackResolver(
|
||||
List.of(staticToolCallbackResolver, springBeanToolCallbackResolver));
|
||||
|
||||
return ToolCallingManager.builder()
|
||||
.observationRegistry(observationRegistry.getIfUnique(() -> ObservationRegistry.NOOP))
|
||||
.toolCallbackResolver(toolCallbackResolver)
|
||||
.toolExecutionExceptionProcessor(new DefaultToolExecutionExceptionProcessor(false))
|
||||
.build();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user