Add ZhiPu model support for AbstractToolCallSupport
Implement ZhiPu function call to work with AbstractToolCallSupport framework. Add unit tests for function call functionality. Resolves #1078.
This commit is contained in:
@@ -17,8 +17,13 @@ package org.springframework.ai.zhipuai;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.ai.chat.messages.AssistantMessage;
|
||||
import org.springframework.ai.chat.messages.MessageType;
|
||||
import org.springframework.ai.chat.messages.ToolResponseMessage;
|
||||
import org.springframework.ai.chat.messages.UserMessage;
|
||||
import org.springframework.ai.chat.metadata.ChatGenerationMetadata;
|
||||
import org.springframework.ai.chat.metadata.ChatResponseMetadata;
|
||||
import org.springframework.ai.chat.model.AbstractToolCallSupport;
|
||||
import org.springframework.ai.chat.model.ChatModel;
|
||||
import org.springframework.ai.chat.model.ChatResponse;
|
||||
import org.springframework.ai.chat.model.Generation;
|
||||
@@ -26,32 +31,35 @@ import org.springframework.ai.chat.model.StreamingChatModel;
|
||||
import org.springframework.ai.chat.prompt.ChatOptions;
|
||||
import org.springframework.ai.chat.prompt.Prompt;
|
||||
import org.springframework.ai.model.ModelOptionsUtils;
|
||||
import org.springframework.ai.model.function.AbstractFunctionCallSupport;
|
||||
import org.springframework.ai.model.function.FunctionCallback;
|
||||
import org.springframework.ai.model.function.FunctionCallbackContext;
|
||||
import org.springframework.ai.retry.RetryUtils;
|
||||
import org.springframework.ai.zhipuai.api.ZhiPuAiApi;
|
||||
import org.springframework.ai.zhipuai.api.ZhiPuAiApi.ChatCompletion;
|
||||
import org.springframework.ai.zhipuai.api.ZhiPuAiApi.ChatCompletion.Choice;
|
||||
import org.springframework.ai.zhipuai.api.ZhiPuAiApi.ChatCompletionChunk;
|
||||
import org.springframework.ai.zhipuai.api.ZhiPuAiApi.ChatCompletionFinishReason;
|
||||
import org.springframework.ai.zhipuai.api.ZhiPuAiApi.ChatCompletionMessage;
|
||||
import org.springframework.ai.zhipuai.api.ZhiPuAiApi.ChatCompletionMessage.ChatCompletionFunction;
|
||||
import org.springframework.ai.zhipuai.api.ZhiPuAiApi.ChatCompletionMessage.MediaContent;
|
||||
import org.springframework.ai.zhipuai.api.ZhiPuAiApi.ChatCompletionMessage.Role;
|
||||
import org.springframework.ai.zhipuai.api.ZhiPuAiApi.ChatCompletionMessage.ToolCall;
|
||||
import org.springframework.ai.zhipuai.api.ZhiPuAiApi.ChatCompletionRequest;
|
||||
import org.springframework.ai.zhipuai.api.ZhiPuAiApi.FunctionTool;
|
||||
import org.springframework.ai.zhipuai.metadata.ZhiPuAiUsage;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.retry.support.RetryTemplate;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
import org.springframework.util.MimeType;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
@@ -65,9 +73,7 @@ import java.util.concurrent.ConcurrentHashMap;
|
||||
* @see ZhiPuAiApi
|
||||
* @since 1.0.0 M1
|
||||
*/
|
||||
public class ZhiPuAiChatModel extends
|
||||
AbstractFunctionCallSupport<ChatCompletionMessage, ZhiPuAiApi.ChatCompletionRequest, ResponseEntity<ChatCompletion>>
|
||||
implements ChatModel, StreamingChatModel {
|
||||
public class ZhiPuAiChatModel extends AbstractToolCallSupport implements ChatModel, StreamingChatModel {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(ZhiPuAiChatModel.class);
|
||||
|
||||
@@ -108,7 +114,7 @@ public class ZhiPuAiChatModel extends
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes a new instance of the ZhiPuAiChatModel.
|
||||
* Initializes an instance of the ZhiPuAiChatModel.
|
||||
* @param zhiPuAiApi The ZhiPuAiApi instance to be used for interacting with the
|
||||
* ZhiPuAI Chat API.
|
||||
* @param options The ZhiPuAiChatOptions to configure the chat model.
|
||||
@@ -117,10 +123,27 @@ public class ZhiPuAiChatModel extends
|
||||
*/
|
||||
public ZhiPuAiChatModel(ZhiPuAiApi zhiPuAiApi, ZhiPuAiChatOptions options,
|
||||
FunctionCallbackContext functionCallbackContext, RetryTemplate retryTemplate) {
|
||||
super(functionCallbackContext);
|
||||
this(zhiPuAiApi, options, functionCallbackContext, List.of(), retryTemplate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes a new instance of the ZhiPuAiChatModel.
|
||||
* @param zhiPuAiApi The ZhiPuAiApi instance to be used for interacting with the
|
||||
* ZhiPuAI Chat API.
|
||||
* @param options The ZhiPuAiChatOptions to configure the chat model.
|
||||
* @param functionCallbackContext The function callback context.
|
||||
* @param toolFunctionCallbacks The tool function callbacks.
|
||||
* @param retryTemplate The retry template.
|
||||
*/
|
||||
public ZhiPuAiChatModel(ZhiPuAiApi zhiPuAiApi, ZhiPuAiChatOptions options,
|
||||
FunctionCallbackContext functionCallbackContext, List<FunctionCallback> toolFunctionCallbacks,
|
||||
RetryTemplate retryTemplate) {
|
||||
super(functionCallbackContext, options, toolFunctionCallbacks);
|
||||
Assert.notNull(zhiPuAiApi, "ZhiPuAiApi must not be null");
|
||||
Assert.notNull(options, "Options must not be null");
|
||||
Assert.notNull(retryTemplate, "RetryTemplate must not be null");
|
||||
Assert.isTrue(CollectionUtils.isEmpty(options.getFunctionCallbacks()),
|
||||
"The default function callbacks must be set via the toolFunctionCallbacks constructor parameter");
|
||||
this.zhiPuAiApi = zhiPuAiApi;
|
||||
this.defaultOptions = options;
|
||||
this.retryTemplate = retryTemplate;
|
||||
@@ -128,103 +151,150 @@ public class ZhiPuAiChatModel extends
|
||||
|
||||
@Override
|
||||
public ChatResponse call(Prompt prompt) {
|
||||
|
||||
ChatCompletionRequest request = createRequest(prompt, false);
|
||||
|
||||
return this.retryTemplate.execute(ctx -> {
|
||||
ResponseEntity<ChatCompletion> completionEntity = this.retryTemplate
|
||||
.execute(ctx -> this.zhiPuAiApi.chatCompletionEntity(request));
|
||||
|
||||
ResponseEntity<ChatCompletion> completionEntity = this.callWithFunctionSupport(request);
|
||||
var chatCompletion = completionEntity.getBody();
|
||||
|
||||
var chatCompletion = completionEntity.getBody();
|
||||
if (chatCompletion == null) {
|
||||
logger.warn("No chat completion returned for prompt: {}", prompt);
|
||||
return new ChatResponse(List.of());
|
||||
}
|
||||
if (chatCompletion == null) {
|
||||
logger.warn("No chat completion returned for prompt: {}", prompt);
|
||||
return new ChatResponse(List.of());
|
||||
}
|
||||
|
||||
List<Generation> generations = chatCompletion.choices().stream().map(choice -> {
|
||||
return new Generation(choice.message().content(), toMap(chatCompletion.id(), choice))
|
||||
.withGenerationMetadata(ChatGenerationMetadata.from(choice.finishReason().name(), null));
|
||||
}).toList();
|
||||
List<Choice> choices = chatCompletion.choices();
|
||||
|
||||
return new ChatResponse(generations);
|
||||
});
|
||||
List<Generation> generations = choices.stream().map(choice -> {
|
||||
// @formatter:off
|
||||
Map<String, Object> metadata = Map.of(
|
||||
"id", chatCompletion.id(),
|
||||
"role", choice.message().role() != null ? choice.message().role().name() : "",
|
||||
"finishReason", choice.finishReason() != null ? choice.finishReason().name() : "");
|
||||
// @formatter:on
|
||||
return buildGeneration(choice, metadata);
|
||||
}).toList();
|
||||
|
||||
ChatResponse chatResponse = new ChatResponse(generations, from(completionEntity.getBody()));
|
||||
|
||||
if (isToolCall(chatResponse,
|
||||
Set.of(ChatCompletionFinishReason.TOOL_CALLS.name(), ChatCompletionFinishReason.STOP.name()))) {
|
||||
var toolCallConversation = handleToolCalls(prompt, chatResponse);
|
||||
// Recursively call the call method with the tool call message
|
||||
// conversation that contains the call responses.
|
||||
return this.call(new Prompt(toolCallConversation, prompt.getOptions()));
|
||||
}
|
||||
|
||||
return chatResponse;
|
||||
}
|
||||
|
||||
private Map<String, Object> toMap(String id, ChatCompletion.Choice choice) {
|
||||
Map<String, Object> map = new HashMap<>();
|
||||
|
||||
var message = choice.message();
|
||||
if (message.role() != null) {
|
||||
map.put("role", message.role().name());
|
||||
}
|
||||
if (choice.finishReason() != null) {
|
||||
map.put("finishReason", choice.finishReason().name());
|
||||
}
|
||||
map.put("id", id);
|
||||
return map;
|
||||
@Override
|
||||
public ChatOptions getDefaultOptions() {
|
||||
return ZhiPuAiChatOptions.fromOptions(this.defaultOptions);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Flux<ChatResponse> stream(Prompt prompt) {
|
||||
|
||||
ChatCompletionRequest request = createRequest(prompt, true);
|
||||
|
||||
return this.retryTemplate.execute(ctx -> {
|
||||
Flux<ChatCompletionChunk> completionChunks = this.retryTemplate
|
||||
.execute(ctx -> this.zhiPuAiApi.chatCompletionStream(request));
|
||||
|
||||
Flux<ZhiPuAiApi.ChatCompletionChunk> completionChunks = this.zhiPuAiApi.chatCompletionStream(request);
|
||||
// For chunked responses, only the first chunk contains the choice role.
|
||||
// The rest of the chunks with same ID share the same role.
|
||||
ConcurrentHashMap<String, String> roleMap = new ConcurrentHashMap<>();
|
||||
|
||||
// For chunked responses, only the first chunk contains the choice role.
|
||||
// The rest of the chunks with same ID share the same role.
|
||||
ConcurrentHashMap<String, String> roleMap = new ConcurrentHashMap<>();
|
||||
|
||||
// Convert the ChatCompletionChunk into a ChatCompletion to be able to reuse
|
||||
// the function call handling logic.
|
||||
return completionChunks.map(chunk -> chunkToChatCompletion(chunk)).map(chatCompletion -> {
|
||||
// Convert the ChatCompletionChunk into a ChatCompletion to be able to reuse
|
||||
// the function call handling logic.
|
||||
Flux<ChatResponse> chatResponse = completionChunks.map(this::chunkToChatCompletion)
|
||||
.switchMap(chatCompletion -> Mono.just(chatCompletion).map(chatCompletion2 -> {
|
||||
try {
|
||||
chatCompletion = handleFunctionCallOrReturn(request, ResponseEntity.of(Optional.of(chatCompletion)))
|
||||
.getBody();
|
||||
|
||||
@SuppressWarnings("null")
|
||||
String id = chatCompletion.id();
|
||||
String id = chatCompletion2.id();
|
||||
|
||||
List<Generation> generations = chatCompletion.choices().stream().map(choice -> {
|
||||
if (choice.message().role() != null) {
|
||||
roleMap.putIfAbsent(id, choice.message().role().name());
|
||||
}
|
||||
String finish = (choice.finishReason() != null ? choice.finishReason().name() : "");
|
||||
var generation = new Generation(choice.message().content(),
|
||||
Map.of("id", id, "role", roleMap.get(id), "finishReason", finish));
|
||||
if (choice.finishReason() != null) {
|
||||
generation = generation.withGenerationMetadata(
|
||||
ChatGenerationMetadata.from(choice.finishReason().name(), null));
|
||||
}
|
||||
return generation;
|
||||
}).toList();
|
||||
// @formatter:off
|
||||
List<Generation> generations = chatCompletion2.choices().stream().map(choice -> {
|
||||
if (choice.message().role() != null) {
|
||||
roleMap.putIfAbsent(id, choice.message().role().name());
|
||||
}
|
||||
Map<String, Object> metadata = Map.of(
|
||||
"id", chatCompletion2.id(),
|
||||
"role", roleMap.getOrDefault(id, ""),
|
||||
"finishReason", choice.finishReason() != null ? choice.finishReason().name() : "");
|
||||
return buildGeneration(choice, metadata);
|
||||
}).toList();
|
||||
// @formatter:on
|
||||
|
||||
return new ChatResponse(generations);
|
||||
if (chatCompletion2.usage() != null) {
|
||||
return new ChatResponse(generations, from(chatCompletion2));
|
||||
}
|
||||
else {
|
||||
return new ChatResponse(generations);
|
||||
}
|
||||
}
|
||||
catch (Exception e) {
|
||||
logger.error("Error processing chat completion", e);
|
||||
return new ChatResponse(List.of());
|
||||
}
|
||||
|
||||
});
|
||||
}));
|
||||
|
||||
return chatResponse.flatMap(response -> {
|
||||
|
||||
if (isToolCall(response,
|
||||
Set.of(ChatCompletionFinishReason.TOOL_CALLS.name(), ChatCompletionFinishReason.STOP.name()))) {
|
||||
var toolCallConversation = handleToolCalls(prompt, response);
|
||||
// Recursively call the stream method with the tool call message
|
||||
// conversation that contains the call responses.
|
||||
return this.stream(new Prompt(toolCallConversation, prompt.getOptions()));
|
||||
}
|
||||
else {
|
||||
return Flux.just(response);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private ChatResponseMetadata from(ChatCompletion result) {
|
||||
Assert.notNull(result, "ZhiPuAI ChatCompletionResult must not be null");
|
||||
return ChatResponseMetadata.builder()
|
||||
.withId(result.id())
|
||||
.withUsage(ZhiPuAiUsage.from(result.usage()))
|
||||
.withModel(result.model())
|
||||
.withKeyValue("created", result.created())
|
||||
.build();
|
||||
}
|
||||
|
||||
private static Generation buildGeneration(Choice choice, Map<String, Object> metadata) {
|
||||
List<AssistantMessage.ToolCall> toolCalls = choice.message().toolCalls() == null ? List.of()
|
||||
: choice.message()
|
||||
.toolCalls()
|
||||
.stream()
|
||||
.map(toolCall -> new AssistantMessage.ToolCall(toolCall.id(), "function",
|
||||
toolCall.function().name(), toolCall.function().arguments()))
|
||||
.toList();
|
||||
|
||||
var assistantMessage = new AssistantMessage(choice.message().content(), metadata, toolCalls);
|
||||
String finishReason = (choice.finishReason() != null ? choice.finishReason().name() : "");
|
||||
var generationMetadata = ChatGenerationMetadata.from(finishReason, null);
|
||||
return new Generation(assistantMessage, generationMetadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the ChatCompletionChunk into a ChatCompletion. The Usage is set to null.
|
||||
* @param chunk the ChatCompletionChunk to convert
|
||||
* @return the ChatCompletion
|
||||
*/
|
||||
private ZhiPuAiApi.ChatCompletion chunkToChatCompletion(ZhiPuAiApi.ChatCompletionChunk chunk) {
|
||||
List<Choice> choices = chunk.choices()
|
||||
.stream()
|
||||
.map(cc -> new Choice(cc.finishReason(), cc.index(), cc.delta(), cc.logprobs()))
|
||||
.toList();
|
||||
private ChatCompletion chunkToChatCompletion(ChatCompletionChunk chunk) {
|
||||
List<ChatCompletion.Choice> choices = chunk.choices().stream().map(cc -> {
|
||||
ChatCompletionMessage delta = cc.delta();
|
||||
if (delta == null) {
|
||||
delta = new ChatCompletionMessage("", Role.ASSISTANT);
|
||||
}
|
||||
return new ChatCompletion.Choice(cc.finishReason(), cc.index(), delta, cc.logprobs());
|
||||
}).toList();
|
||||
|
||||
return new ZhiPuAiApi.ChatCompletion(chunk.id(), choices, chunk.created(), chunk.model(),
|
||||
chunk.systemFingerprint(), "chat.completion", null);
|
||||
return new ChatCompletion(chunk.id(), choices, chunk.created(), chunk.model(), chunk.systemFingerprint(),
|
||||
"chat.completion", null);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -232,54 +302,82 @@ public class ZhiPuAiChatModel extends
|
||||
*/
|
||||
ChatCompletionRequest createRequest(Prompt prompt, boolean stream) {
|
||||
|
||||
Set<String> functionsForThisRequest = new HashSet<>();
|
||||
List<ChatCompletionMessage> chatCompletionMessages = prompt.getInstructions().stream().map(message -> {
|
||||
if (message.getMessageType() == MessageType.USER || message.getMessageType() == MessageType.SYSTEM) {
|
||||
Object content = message.getContent();
|
||||
if (message instanceof UserMessage userMessage) {
|
||||
if (!CollectionUtils.isEmpty(userMessage.getMedia())) {
|
||||
List<MediaContent> contentList = new ArrayList<>(
|
||||
List.of(new MediaContent(message.getContent())));
|
||||
|
||||
List<ChatCompletionMessage> chatCompletionMessages = prompt.getInstructions().stream().map(m -> {
|
||||
// Add text content.
|
||||
List<MediaContent> contents = new ArrayList<>(List.of(new MediaContent(m.getContent())));
|
||||
if (m instanceof UserMessage userMessage) {
|
||||
if (!CollectionUtils.isEmpty(userMessage.getMedia())) {
|
||||
// Add media content.
|
||||
contents.addAll(userMessage.getMedia()
|
||||
.stream()
|
||||
.map(media -> new MediaContent(
|
||||
new MediaContent.ImageUrl(this.fromMediaData(media.getMimeType(), media.getData()))))
|
||||
.toList());
|
||||
contentList.addAll(userMessage.getMedia()
|
||||
.stream()
|
||||
.map(media -> new MediaContent(new MediaContent.ImageUrl(
|
||||
this.fromMediaData(media.getMimeType(), media.getData()))))
|
||||
.toList());
|
||||
|
||||
content = contentList;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new ChatCompletionMessage(contents, ChatCompletionMessage.Role.valueOf(m.getMessageType().name()));
|
||||
}).toList();
|
||||
return List.of(new ChatCompletionMessage(content,
|
||||
ChatCompletionMessage.Role.valueOf(message.getMessageType().name())));
|
||||
}
|
||||
else if (message.getMessageType() == MessageType.ASSISTANT) {
|
||||
var assistantMessage = (AssistantMessage) message;
|
||||
List<ToolCall> toolCalls = null;
|
||||
if (!CollectionUtils.isEmpty(assistantMessage.getToolCalls())) {
|
||||
toolCalls = assistantMessage.getToolCalls().stream().map(toolCall -> {
|
||||
var function = new ChatCompletionFunction(toolCall.name(), toolCall.arguments());
|
||||
return new ToolCall(toolCall.id(), toolCall.type(), function);
|
||||
}).toList();
|
||||
}
|
||||
return List.of(new ChatCompletionMessage(assistantMessage.getContent(),
|
||||
ChatCompletionMessage.Role.ASSISTANT, null, null, toolCalls));
|
||||
}
|
||||
else if (message.getMessageType() == MessageType.TOOL) {
|
||||
ToolResponseMessage toolMessage = (ToolResponseMessage) message;
|
||||
|
||||
toolMessage.getResponses().forEach(response -> {
|
||||
Assert.isTrue(response.id() != null, "ToolResponseMessage must have an id");
|
||||
Assert.isTrue(response.name() != null, "ToolResponseMessage must have a name");
|
||||
});
|
||||
|
||||
return toolMessage.getResponses()
|
||||
.stream()
|
||||
.map(tr -> new ChatCompletionMessage(tr.responseData(), ChatCompletionMessage.Role.TOOL, tr.name(),
|
||||
tr.id(), null))
|
||||
.toList();
|
||||
}
|
||||
else {
|
||||
throw new IllegalArgumentException("Unsupported message type: " + message.getMessageType());
|
||||
}
|
||||
}).flatMap(List::stream).toList();
|
||||
|
||||
ChatCompletionRequest request = new ChatCompletionRequest(chatCompletionMessages, stream);
|
||||
|
||||
Set<String> enabledToolsToUse = new HashSet<>();
|
||||
|
||||
if (prompt.getOptions() != null) {
|
||||
ZhiPuAiChatOptions updatedRuntimeOptions = ModelOptionsUtils.copyToTarget(prompt.getOptions(),
|
||||
ChatOptions.class, ZhiPuAiChatOptions.class);
|
||||
|
||||
Set<String> promptEnabledFunctions = this.handleFunctionCallbackConfigurations(updatedRuntimeOptions,
|
||||
IS_RUNTIME_CALL);
|
||||
functionsForThisRequest.addAll(promptEnabledFunctions);
|
||||
enabledToolsToUse.addAll(this.runtimeFunctionCallbackConfigurations(updatedRuntimeOptions));
|
||||
|
||||
request = ModelOptionsUtils.merge(updatedRuntimeOptions, request, ChatCompletionRequest.class);
|
||||
}
|
||||
|
||||
if (this.defaultOptions != null) {
|
||||
|
||||
Set<String> defaultEnabledFunctions = this.handleFunctionCallbackConfigurations(this.defaultOptions,
|
||||
!IS_RUNTIME_CALL);
|
||||
|
||||
functionsForThisRequest.addAll(defaultEnabledFunctions);
|
||||
|
||||
request = ModelOptionsUtils.merge(request, this.defaultOptions, ChatCompletionRequest.class);
|
||||
if (!CollectionUtils.isEmpty(this.defaultOptions.getFunctions())) {
|
||||
enabledToolsToUse.addAll(this.defaultOptions.getFunctions());
|
||||
}
|
||||
|
||||
// Add the enabled functions definitions to the request's tools parameter.
|
||||
if (!CollectionUtils.isEmpty(functionsForThisRequest)) {
|
||||
request = ModelOptionsUtils.merge(request, this.defaultOptions, ChatCompletionRequest.class);
|
||||
|
||||
if (!CollectionUtils.isEmpty(enabledToolsToUse)) {
|
||||
|
||||
request = ModelOptionsUtils.merge(
|
||||
ZhiPuAiChatOptions.builder().withTools(this.getFunctionTools(functionsForThisRequest)).build(),
|
||||
request, ChatCompletionRequest.class);
|
||||
ZhiPuAiChatOptions.builder().withTools(this.getFunctionTools(enabledToolsToUse)).build(), request,
|
||||
ChatCompletionRequest.class);
|
||||
}
|
||||
|
||||
return request;
|
||||
@@ -289,7 +387,7 @@ public class ZhiPuAiChatModel extends
|
||||
if (mediaContentData instanceof byte[] bytes) {
|
||||
// Assume the bytes are an image. So, convert the bytes to a base64 encoded
|
||||
// following the prefix pattern.
|
||||
return Base64.getEncoder().encodeToString(bytes);
|
||||
return String.format("data:%s;base64,%s", mimeType.toString(), Base64.getEncoder().encodeToString(bytes));
|
||||
}
|
||||
else if (mediaContentData instanceof String text) {
|
||||
// Assume the text is a URLs or a base64 encoded image prefixed by the user.
|
||||
@@ -301,87 +399,12 @@ public class ZhiPuAiChatModel extends
|
||||
}
|
||||
}
|
||||
|
||||
private List<ZhiPuAiApi.FunctionTool> getFunctionTools(Set<String> functionNames) {
|
||||
private List<FunctionTool> getFunctionTools(Set<String> functionNames) {
|
||||
return this.resolveFunctionCallbacks(functionNames).stream().map(functionCallback -> {
|
||||
var function = new ZhiPuAiApi.FunctionTool.Function(functionCallback.getDescription(),
|
||||
functionCallback.getName(), functionCallback.getInputTypeSchema());
|
||||
return new ZhiPuAiApi.FunctionTool(function);
|
||||
var function = new FunctionTool.Function(functionCallback.getDescription(), functionCallback.getName(),
|
||||
functionCallback.getInputTypeSchema());
|
||||
return new FunctionTool(function);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ChatCompletionRequest doCreateToolResponseRequest(ChatCompletionRequest previousRequest,
|
||||
ChatCompletionMessage responseMessage, List<ChatCompletionMessage> conversationHistory) {
|
||||
|
||||
// Every tool-call item requires a separate function call and a response (TOOL)
|
||||
// message.
|
||||
for (ToolCall toolCall : responseMessage.toolCalls()) {
|
||||
|
||||
var functionName = toolCall.function().name();
|
||||
String functionArguments = toolCall.function().arguments();
|
||||
|
||||
if (!this.functionCallbackRegister.containsKey(functionName)) {
|
||||
throw new IllegalStateException("No function callback found for function name: " + functionName);
|
||||
}
|
||||
|
||||
String functionResponse = this.functionCallbackRegister.get(functionName).call(functionArguments);
|
||||
|
||||
// Add the function response to the conversation.
|
||||
conversationHistory
|
||||
.add(new ChatCompletionMessage(functionResponse, Role.TOOL, functionName, toolCall.id(), null));
|
||||
}
|
||||
|
||||
// Recursively call chatCompletionWithTools until the model doesn't call a
|
||||
// functions anymore.
|
||||
ChatCompletionRequest newRequest = new ChatCompletionRequest(conversationHistory, false);
|
||||
newRequest = ModelOptionsUtils.merge(newRequest, previousRequest, ChatCompletionRequest.class);
|
||||
|
||||
return newRequest;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected List<ChatCompletionMessage> doGetUserMessages(ChatCompletionRequest request) {
|
||||
return request.messages();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ChatCompletionMessage doGetToolResponseMessage(ResponseEntity<ChatCompletion> chatCompletion) {
|
||||
return chatCompletion.getBody().choices().iterator().next().message();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ResponseEntity<ChatCompletion> doChatCompletion(ChatCompletionRequest request) {
|
||||
return this.zhiPuAiApi.chatCompletionEntity(request);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Flux<ResponseEntity<ChatCompletion>> doChatCompletionStream(ChatCompletionRequest request) {
|
||||
return this.zhiPuAiApi.chatCompletionStream(request)
|
||||
.map(this::chunkToChatCompletion)
|
||||
.map(Optional::ofNullable)
|
||||
.map(ResponseEntity::of);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean isToolFunctionCall(ResponseEntity<ChatCompletion> chatCompletion) {
|
||||
var body = chatCompletion.getBody();
|
||||
if (body == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
var choices = body.choices();
|
||||
if (CollectionUtils.isEmpty(choices)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
var choice = choices.get(0);
|
||||
return !CollectionUtils.isEmpty(choice.message().toolCalls())
|
||||
&& choice.finishReason() == ChatCompletionFinishReason.TOOL_CALLS;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ChatOptions getDefaultOptions() {
|
||||
return ZhiPuAiChatOptions.fromOptions(this.defaultOptions);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -33,8 +33,10 @@ import org.springframework.web.reactive.function.client.WebClient;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
@@ -684,7 +686,7 @@ public class ZhiPuAiApi {
|
||||
.takeUntil(SSE_DONE_PREDICATE)
|
||||
.filter(SSE_DONE_PREDICATE.negate())
|
||||
.map(content -> ModelOptionsUtils.jsonToObject(content, ChatCompletionChunk.class))
|
||||
.map(chunk -> {
|
||||
.map(chunk -> {
|
||||
if (this.chunkMerger.isStreamingToolFunctionCall(chunk)) {
|
||||
isInsideTool.set(true);
|
||||
}
|
||||
@@ -750,6 +752,25 @@ public class ZhiPuAiApi {
|
||||
public Embedding(Integer index, float[] embedding) {
|
||||
this(index, embedding, "embedding");
|
||||
}
|
||||
@Override public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (!(o instanceof Embedding embedding1)) return false;
|
||||
return Objects.equals(index, embedding1.index) && Arrays.equals(embedding, embedding1.embedding) && Objects.equals(object, embedding1.object);
|
||||
}
|
||||
@Override
|
||||
public int hashCode() {
|
||||
int result = Objects.hash(index, object);
|
||||
result = 31 * result + Arrays.hashCode(embedding);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override public String toString() {
|
||||
return "Embedding{" +
|
||||
"index=" + index +
|
||||
", embedding=" + Arrays.toString(embedding) +
|
||||
", object='" + object + '\'' +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -35,8 +35,8 @@ public class MockWeatherService implements Function<MockWeatherService.Request,
|
||||
@JsonClassDescription("Weather API request")
|
||||
public record Request(@JsonProperty(required = true,
|
||||
value = "location") @JsonPropertyDescription("The city and state e.g. San Francisco, CA") String location,
|
||||
@JsonProperty(required = true, value = "lat") @JsonPropertyDescription("The city latitude") double lat,
|
||||
@JsonProperty(required = true, value = "lon") @JsonPropertyDescription("The city longitude") double lon,
|
||||
@JsonProperty(value = "lat") @JsonPropertyDescription("The city latitude") double lat,
|
||||
@JsonProperty(value = "lon") @JsonPropertyDescription("The city longitude") double lon,
|
||||
@JsonProperty(required = true, value = "unit") @JsonPropertyDescription("Temperature unit") Unit unit) {
|
||||
}
|
||||
|
||||
|
||||
@@ -17,8 +17,13 @@ package org.springframework.ai.zhipuai.api;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
|
||||
import org.springframework.ai.zhipuai.api.ZhiPuAiApi.*;
|
||||
import org.springframework.ai.zhipuai.api.ZhiPuAiApi.ChatCompletion;
|
||||
import org.springframework.ai.zhipuai.api.ZhiPuAiApi.ChatCompletionChunk;
|
||||
import org.springframework.ai.zhipuai.api.ZhiPuAiApi.ChatCompletionMessage;
|
||||
import org.springframework.ai.zhipuai.api.ZhiPuAiApi.ChatCompletionMessage.Role;
|
||||
import org.springframework.ai.zhipuai.api.ZhiPuAiApi.ChatCompletionRequest;
|
||||
import org.springframework.ai.zhipuai.api.ZhiPuAiApi.Embedding;
|
||||
import org.springframework.ai.zhipuai.api.ZhiPuAiApi.EmbeddingList;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
|
||||
@@ -56,8 +56,8 @@ public class ZhiPuAiApiToolFunctionCallIT {
|
||||
public void toolFunctionCall() {
|
||||
|
||||
// Step 1: send the conversation and available functions to the model
|
||||
var message = new ChatCompletionMessage("What's the weather like in San Francisco, Tokyo, and Paris?",
|
||||
Role.USER);
|
||||
var message = new ChatCompletionMessage(
|
||||
"What's the weather like in San Francisco? Return the temperature in Celsius.", Role.USER);
|
||||
|
||||
var functionTool = new ZhiPuAiApi.FunctionTool(Type.FUNCTION,
|
||||
new ZhiPuAiApi.FunctionTool.Function(
|
||||
@@ -119,7 +119,8 @@ public class ZhiPuAiApiToolFunctionCallIT {
|
||||
}
|
||||
}
|
||||
|
||||
var functionResponseRequest = new ChatCompletionRequest(messages, GLM_4.value, 0.8f);
|
||||
var functionResponseRequest = new ChatCompletionRequest(messages, GLM_4.value, List.of(functionTool),
|
||||
ToolChoiceBuilder.AUTO);
|
||||
|
||||
ResponseEntity<ChatCompletion> chatCompletion2 = zhiPuAiApi.chatCompletionEntity(functionResponseRequest);
|
||||
|
||||
@@ -130,10 +131,6 @@ public class ZhiPuAiApiToolFunctionCallIT {
|
||||
assertThat(chatCompletion2.getBody().choices().get(0).message().role()).isEqualTo(Role.ASSISTANT);
|
||||
assertThat(chatCompletion2.getBody().choices().get(0).message().content()).contains("San Francisco")
|
||||
.containsAnyOf("30.0°C", "30°C", "30.0°F", "30°F");
|
||||
assertThat(chatCompletion2.getBody().choices().get(0).message().content()).contains("Tokyo")
|
||||
.containsAnyOf("10.0°C", "10°C", "10.0°F", "10°F");
|
||||
assertThat(chatCompletion2.getBody().choices().get(0).message().content()).contains("Paris")
|
||||
.containsAnyOf("15.0°C", "15°C", "15.0°F", "15°F");
|
||||
}
|
||||
|
||||
private static <T> T fromJson(String json, Class<T> targetClass) {
|
||||
|
||||
@@ -22,7 +22,6 @@ import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.ai.chat.messages.AssistantMessage;
|
||||
import org.springframework.ai.model.Media;
|
||||
import org.springframework.ai.chat.messages.Message;
|
||||
import org.springframework.ai.chat.messages.UserMessage;
|
||||
import org.springframework.ai.chat.model.ChatModel;
|
||||
@@ -35,6 +34,7 @@ import org.springframework.ai.chat.prompt.SystemPromptTemplate;
|
||||
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.model.function.FunctionCallbackWrapper;
|
||||
import org.springframework.ai.zhipuai.ZhiPuAiChatOptions;
|
||||
import org.springframework.ai.zhipuai.ZhiPuAiTestConfiguration;
|
||||
@@ -224,7 +224,8 @@ class ZhiPuAiChatModelIT {
|
||||
@Test
|
||||
void functionCallTest() {
|
||||
|
||||
UserMessage userMessage = new UserMessage("What's the weather like in San Francisco, Tokyo, and Paris?");
|
||||
UserMessage userMessage = new UserMessage(
|
||||
"What's the weather like in San Francisco, Tokyo, and Paris? Return the temperature in Celsius.");
|
||||
|
||||
List<Message> messages = new ArrayList<>(List.of(userMessage));
|
||||
|
||||
@@ -249,11 +250,13 @@ class ZhiPuAiChatModelIT {
|
||||
@Test
|
||||
void streamFunctionCallTest() {
|
||||
|
||||
UserMessage userMessage = new UserMessage("What's the weather like in San Francisco, Tokyo, and Paris?");
|
||||
UserMessage userMessage = new UserMessage(
|
||||
"What's the weather like in San Francisco, Tokyo, and Paris? Return the temperature in Celsius.");
|
||||
|
||||
List<Message> messages = new ArrayList<>(List.of(userMessage));
|
||||
|
||||
var promptOptions = ZhiPuAiChatOptions.builder()
|
||||
.withModel(ZhiPuAiApi.ChatModel.GLM_4.getValue())
|
||||
.withFunctionCallbacks(List.of(FunctionCallbackWrapper.builder(new MockWeatherService())
|
||||
.withName("getCurrentWeather")
|
||||
.withDescription("Get the weather in location")
|
||||
|
||||
@@ -33,15 +33,11 @@ import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.retry.support.RetryTemplate;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.client.ResponseErrorHandler;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* @author Geng Rong
|
||||
@@ -64,16 +60,8 @@ public class ZhiPuAiAutoConfiguration {
|
||||
var zhiPuAiApi = zhiPuAiApi(chatProperties.getBaseUrl(), commonProperties.getBaseUrl(),
|
||||
chatProperties.getApiKey(), commonProperties.getApiKey(), restClientBuilder, responseErrorHandler);
|
||||
|
||||
ZhiPuAiChatModel chatModel = new ZhiPuAiChatModel(zhiPuAiApi, chatProperties.getOptions(),
|
||||
functionCallbackContext, retryTemplate);
|
||||
|
||||
if (!CollectionUtils.isEmpty(toolFunctionCallbacks)) {
|
||||
Map<String, FunctionCallback> toolFunctionCallbackMap = toolFunctionCallbacks.stream()
|
||||
.collect(Collectors.toMap(FunctionCallback::getName, Function.identity(), (a, b) -> b));
|
||||
chatModel.getFunctionCallbackRegister().putAll(toolFunctionCallbackMap);
|
||||
}
|
||||
|
||||
return chatModel;
|
||||
return new ZhiPuAiChatModel(zhiPuAiApi, chatProperties.getOptions(), functionCallbackContext,
|
||||
toolFunctionCallbacks, retryTemplate);
|
||||
}
|
||||
|
||||
@Bean
|
||||
|
||||
@@ -20,8 +20,8 @@ import org.apache.commons.logging.LogFactory;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
|
||||
import org.springframework.ai.autoconfigure.retry.SpringAiRetryAutoConfiguration;
|
||||
import org.springframework.ai.chat.model.ChatResponse;
|
||||
import org.springframework.ai.chat.messages.UserMessage;
|
||||
import org.springframework.ai.chat.model.ChatResponse;
|
||||
import org.springframework.ai.chat.prompt.Prompt;
|
||||
import org.springframework.ai.embedding.EmbeddingResponse;
|
||||
import org.springframework.ai.image.ImagePrompt;
|
||||
@@ -89,7 +89,7 @@ public class ZhiPuAiAutoConfigurationIT {
|
||||
assertThat(embeddingResponse.getResults().get(1).getOutput()).isNotEmpty();
|
||||
assertThat(embeddingResponse.getResults().get(1).getIndex()).isEqualTo(1);
|
||||
|
||||
assertThat(embeddingModel.dimensions()).isEqualTo(1536);
|
||||
assertThat(embeddingModel.dimensions()).isEqualTo(1024);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -21,10 +21,10 @@ import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.ai.autoconfigure.retry.SpringAiRetryAutoConfiguration;
|
||||
import org.springframework.ai.autoconfigure.zhipuai.ZhiPuAiAutoConfiguration;
|
||||
import org.springframework.ai.chat.model.ChatResponse;
|
||||
import org.springframework.ai.chat.model.Generation;
|
||||
import org.springframework.ai.chat.messages.AssistantMessage;
|
||||
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.model.function.FunctionCallbackWrapper;
|
||||
import org.springframework.ai.zhipuai.ZhiPuAiChatModel;
|
||||
@@ -58,7 +58,8 @@ public class FunctionCallbackInPromptIT {
|
||||
|
||||
ZhiPuAiChatModel chatModel = context.getBean(ZhiPuAiChatModel.class);
|
||||
|
||||
UserMessage userMessage = new UserMessage("What's the weather like in San Francisco, Tokyo, and Paris?");
|
||||
UserMessage userMessage = new UserMessage(
|
||||
"What's the weather like in San Francisco, Tokyo, and Paris? Return the temperature in Celsius.");
|
||||
|
||||
var promptOptions = ZhiPuAiChatOptions.builder()
|
||||
.withFunctionCallbacks(List.of(FunctionCallbackWrapper.builder(new MockWeatherService())
|
||||
@@ -83,7 +84,8 @@ public class FunctionCallbackInPromptIT {
|
||||
|
||||
ZhiPuAiChatModel chatModel = context.getBean(ZhiPuAiChatModel.class);
|
||||
|
||||
UserMessage userMessage = new UserMessage("What's the weather like in San Francisco, Tokyo, and Paris?");
|
||||
UserMessage userMessage = new UserMessage(
|
||||
"What's the weather like in San Francisco, Tokyo, and Paris? Return the temperature in Celsius.");
|
||||
|
||||
var promptOptions = ZhiPuAiChatOptions.builder()
|
||||
.withFunctionCallbacks(List.of(FunctionCallbackWrapper.builder(new MockWeatherService())
|
||||
|
||||
@@ -21,10 +21,10 @@ import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.ai.autoconfigure.retry.SpringAiRetryAutoConfiguration;
|
||||
import org.springframework.ai.autoconfigure.zhipuai.ZhiPuAiAutoConfiguration;
|
||||
import org.springframework.ai.chat.model.ChatResponse;
|
||||
import org.springframework.ai.chat.model.Generation;
|
||||
import org.springframework.ai.chat.messages.AssistantMessage;
|
||||
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.model.function.FunctionCallingOptions;
|
||||
import org.springframework.ai.model.function.FunctionCallingOptionsBuilder.PortableFunctionCallingOptions;
|
||||
@@ -65,7 +65,8 @@ class FunctionCallbackWithPlainFunctionBeanIT {
|
||||
ZhiPuAiChatModel chatModel = context.getBean(ZhiPuAiChatModel.class);
|
||||
|
||||
// Test weatherFunction
|
||||
UserMessage userMessage = new UserMessage("What's the weather like in San Francisco, Tokyo, and Paris?");
|
||||
UserMessage userMessage = new UserMessage(
|
||||
"What's the weather like in San Francisco, Tokyo, and Paris? Return the temperature in Celsius.");
|
||||
|
||||
ChatResponse response = chatModel.call(new Prompt(List.of(userMessage),
|
||||
ZhiPuAiChatOptions.builder().withFunction("weatherFunction").build()));
|
||||
@@ -92,7 +93,8 @@ class FunctionCallbackWithPlainFunctionBeanIT {
|
||||
ZhiPuAiChatModel chatModel = context.getBean(ZhiPuAiChatModel.class);
|
||||
|
||||
// Test weatherFunction
|
||||
UserMessage userMessage = new UserMessage("What's the weather like in San Francisco, Tokyo, and Paris?");
|
||||
UserMessage userMessage = new UserMessage(
|
||||
"What's the weather like in San Francisco, Tokyo, and Paris? Return the temperature in Celsius.");
|
||||
|
||||
PortableFunctionCallingOptions functionOptions = FunctionCallingOptions.builder()
|
||||
.withFunction("weatherFunction")
|
||||
@@ -111,7 +113,8 @@ class FunctionCallbackWithPlainFunctionBeanIT {
|
||||
ZhiPuAiChatModel chatModel = context.getBean(ZhiPuAiChatModel.class);
|
||||
|
||||
// Test weatherFunction
|
||||
UserMessage userMessage = new UserMessage("What's the weather like in San Francisco, Tokyo, and Paris?");
|
||||
UserMessage userMessage = new UserMessage(
|
||||
"What's the weather like in San Francisco, Tokyo, and Paris? Return the temperature in Celsius.");
|
||||
|
||||
Flux<ChatResponse> response = chatModel.stream(new Prompt(List.of(userMessage),
|
||||
ZhiPuAiChatOptions.builder().withFunction("weatherFunction").build()));
|
||||
|
||||
@@ -21,10 +21,10 @@ import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.ai.autoconfigure.retry.SpringAiRetryAutoConfiguration;
|
||||
import org.springframework.ai.autoconfigure.zhipuai.ZhiPuAiAutoConfiguration;
|
||||
import org.springframework.ai.chat.model.ChatResponse;
|
||||
import org.springframework.ai.chat.model.Generation;
|
||||
import org.springframework.ai.chat.messages.AssistantMessage;
|
||||
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.model.function.FunctionCallback;
|
||||
import org.springframework.ai.model.function.FunctionCallbackWrapper;
|
||||
@@ -62,7 +62,8 @@ public class FunctionCallbackWrapperIT {
|
||||
|
||||
ZhiPuAiChatModel chatModel = context.getBean(ZhiPuAiChatModel.class);
|
||||
|
||||
UserMessage userMessage = new UserMessage("What's the weather like in San Francisco, Tokyo, and Paris?");
|
||||
UserMessage userMessage = new UserMessage(
|
||||
"What's the weather like in San Francisco, Tokyo, and Paris? Return the temperature in Celsius.");
|
||||
|
||||
ChatResponse response = chatModel.call(
|
||||
new Prompt(List.of(userMessage), ZhiPuAiChatOptions.builder().withFunction("WeatherInfo").build()));
|
||||
@@ -80,7 +81,8 @@ public class FunctionCallbackWrapperIT {
|
||||
|
||||
ZhiPuAiChatModel chatModel = context.getBean(ZhiPuAiChatModel.class);
|
||||
|
||||
UserMessage userMessage = new UserMessage("What's the weather like in San Francisco, Tokyo, and Paris?");
|
||||
UserMessage userMessage = new UserMessage(
|
||||
"What's the weather like in San Francisco, Tokyo, and Paris? Return the temperature in Celsius.");
|
||||
|
||||
Flux<ChatResponse> response = chatModel.stream(
|
||||
new Prompt(List.of(userMessage), ZhiPuAiChatOptions.builder().withFunction("WeatherInfo").build()));
|
||||
|
||||
Reference in New Issue
Block a user