From 4040c73b122bc7b146790f3c38e79e84bf23ffed Mon Sep 17 00:00:00 2001 From: jiangsier-xyz <126842484+jiangsier-xyz@users.noreply.github.com> Date: Tue, 22 Apr 2025 11:53:42 +0800 Subject: [PATCH 01/13] feat: support Qwen models provided by Alibaba Cloud This implementation relies on the Alibaba Cloud official SDK. API reference: https://www.alibabacloud.com/help/en/model-studio/use-qwen-by-calling-api How to obtain an API-KEY: https://www.alibabacloud.com/help/en/model-studio/get-api-key Signed-off-by: jiangsier-xyz <126842484+jiangsier-xyz@users.noreply.github.com> --- models/spring-ai-qwen/pom.xml | 89 ++ .../ai/qwen/QwenChatModel.java | 253 ++++ .../ai/qwen/QwenChatOptions.java | 799 +++++++++++++ .../ai/qwen/aot/QwenRuntimeHints.java | 21 + .../springframework/ai/qwen/api/QwenApi.java | 329 ++++++ .../ai/qwen/api/QwenApiHelper.java | 1013 +++++++++++++++++ .../ai/qwen/api/QwenModel.java | 71 ++ .../ai/qwen/api/QwenSearchInfo.java | 31 + .../ai/qwen/api/QwenSearchResult.java | 63 + .../resources/META-INF/spring/aot.factories | 2 + .../ai/qwen/MockWeatherService.java | 76 ++ .../ai/qwen/QwenChatModelIT.java | 305 +++++ .../ai/qwen/QwenChatModelObservationIT.java | 177 +++ .../ai/qwen/QwenChatModelToolCallIT.java | 192 ++++ .../ai/qwen/aot/QwenRuntimeHintsTests.java | 29 + .../ai/qwen/api/MockImageContentFilter.java | 52 + .../ai/qwen/api/QwenApiIT.java | 156 +++ .../src/test/resources/multimodal.test.png | Bin 0 -> 123394 bytes pom.xml | 1 + spring-ai-bom/pom.xml | 6 + .../observation/conventions/AiProvider.java | 7 +- 21 files changed, 3671 insertions(+), 1 deletion(-) create mode 100644 models/spring-ai-qwen/pom.xml create mode 100644 models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/QwenChatModel.java create mode 100644 models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/QwenChatOptions.java create mode 100644 models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/aot/QwenRuntimeHints.java create mode 100644 models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/api/QwenApi.java create mode 100644 models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/api/QwenApiHelper.java create mode 100644 models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/api/QwenModel.java create mode 100644 models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/api/QwenSearchInfo.java create mode 100644 models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/api/QwenSearchResult.java create mode 100644 models/spring-ai-qwen/src/main/resources/META-INF/spring/aot.factories create mode 100644 models/spring-ai-qwen/src/test/java/org/springframework/ai/qwen/MockWeatherService.java create mode 100644 models/spring-ai-qwen/src/test/java/org/springframework/ai/qwen/QwenChatModelIT.java create mode 100644 models/spring-ai-qwen/src/test/java/org/springframework/ai/qwen/QwenChatModelObservationIT.java create mode 100644 models/spring-ai-qwen/src/test/java/org/springframework/ai/qwen/QwenChatModelToolCallIT.java create mode 100644 models/spring-ai-qwen/src/test/java/org/springframework/ai/qwen/aot/QwenRuntimeHintsTests.java create mode 100644 models/spring-ai-qwen/src/test/java/org/springframework/ai/qwen/api/MockImageContentFilter.java create mode 100644 models/spring-ai-qwen/src/test/java/org/springframework/ai/qwen/api/QwenApiIT.java create mode 100644 models/spring-ai-qwen/src/test/resources/multimodal.test.png diff --git a/models/spring-ai-qwen/pom.xml b/models/spring-ai-qwen/pom.xml new file mode 100644 index 00000000000..21f4ba1cfc4 --- /dev/null +++ b/models/spring-ai-qwen/pom.xml @@ -0,0 +1,89 @@ + + + + + 4.0.0 + + org.springframework.ai + spring-ai-parent + 1.0.0-SNAPSHOT + ../../pom.xml + + spring-ai-qwen + jar + Spring AI Model - Qwen + Qwen models support + https://github.com/spring-projects/spring-ai + + + https://github.com/spring-projects/spring-ai + git://github.com/spring-projects/spring-ai.git + git@github.com:spring-projects/spring-ai.git + + + + 2.18.4 + + + + + + + org.springframework.ai + spring-ai-client-chat + ${project.parent.version} + + + + org.springframework.ai + spring-ai-core + ${project.parent.version} + + + + org.springframework + spring-context-support + + + + org.slf4j + slf4j-api + + + + com.alibaba + dashscope-sdk-java + ${dashscope.version} + + + org.slf4j + slf4j-simple + + + + + + + org.springframework.ai + spring-ai-test + ${project.version} + test + + + + diff --git a/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/QwenChatModel.java b/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/QwenChatModel.java new file mode 100644 index 00000000000..ba38299b174 --- /dev/null +++ b/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/QwenChatModel.java @@ -0,0 +1,253 @@ +package org.springframework.ai.qwen; + +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationRegistry; +import io.micrometer.observation.contextpropagation.ObservationThreadLocalAccessor; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.model.MessageAggregator; +import org.springframework.ai.chat.observation.ChatModelObservationContext; +import org.springframework.ai.chat.observation.ChatModelObservationConvention; +import org.springframework.ai.chat.observation.ChatModelObservationDocumentation; +import org.springframework.ai.chat.observation.DefaultChatModelObservationConvention; +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.FunctionCallingOptions; +import org.springframework.ai.model.tool.DefaultToolExecutionEligibilityPredicate; +import org.springframework.ai.model.tool.ToolCallingChatOptions; +import org.springframework.ai.model.tool.ToolCallingManager; +import org.springframework.ai.model.tool.ToolExecutionEligibilityPredicate; +import org.springframework.ai.model.tool.ToolExecutionResult; +import org.springframework.ai.observation.conventions.AiProvider; +import org.springframework.ai.qwen.api.QwenApi; +import org.springframework.ai.qwen.api.QwenModel; +import org.springframework.util.Assert; +import reactor.core.publisher.Flux; +import reactor.core.scheduler.Schedulers; + +import static org.springframework.ai.qwen.api.QwenApiHelper.getOrDefault; + +public class QwenChatModel implements ChatModel { + + private static final ChatModelObservationConvention DEFAULT_OBSERVATION_CONVENTION = new DefaultChatModelObservationConvention(); + + private static final ToolCallingManager DEFAULT_TOOL_CALLING_MANAGER = ToolCallingManager.builder().build(); + + private final QwenApi qwenApi; + + private final QwenChatOptions defaultOptions; + + private final ObservationRegistry observationRegistry; + + private final ToolCallingManager toolCallingManager; + + private final ToolExecutionEligibilityPredicate toolExecutionEligibilityPredicate; + + private ChatModelObservationConvention observationConvention = DEFAULT_OBSERVATION_CONVENTION; + + public QwenChatModel(QwenApi openAiApi, QwenChatOptions defaultOptions, ToolCallingManager toolCallingManager, + ObservationRegistry observationRegistry) { + this(openAiApi, defaultOptions, toolCallingManager, observationRegistry, + new DefaultToolExecutionEligibilityPredicate()); + } + + public QwenChatModel(QwenApi qwenApi, QwenChatOptions defaultOptions, ToolCallingManager toolCallingManager, + ObservationRegistry observationRegistry, + ToolExecutionEligibilityPredicate toolExecutionEligibilityPredicate) { + Assert.notNull(qwenApi, "qwenApi cannot be null"); + Assert.notNull(defaultOptions, "defaultOptions cannot be null"); + Assert.notNull(observationRegistry, "observationRegistry cannot be null"); + Assert.notNull(toolExecutionEligibilityPredicate, "toolExecutionEligibilityPredicate cannot be null"); + this.qwenApi = qwenApi; + this.defaultOptions = defaultOptions; + this.toolCallingManager = getOrDefault(toolCallingManager, DEFAULT_TOOL_CALLING_MANAGER); + this.observationRegistry = observationRegistry; + this.toolExecutionEligibilityPredicate = toolExecutionEligibilityPredicate; + } + + public static Builder builder() { + return new Builder(); + } + + @Override + public ChatResponse call(Prompt prompt) { + Prompt requestPrompt = buildRequestPrompt(prompt); + return internalCall(requestPrompt, null); + } + + @Override + public Flux stream(Prompt prompt) { + Prompt requestPrompt = buildRequestPrompt(prompt); + return this.internalStream(requestPrompt, null); + } + + @Override + public ChatOptions getDefaultOptions() { + return QwenChatOptions.fromOptions(this.defaultOptions); + } + + /** + * Use the provided convention for reporting observation data + * @param observationConvention The provided convention + */ + public void setObservationConvention(ChatModelObservationConvention observationConvention) { + Assert.notNull(observationConvention, "observationConvention cannot be null"); + this.observationConvention = observationConvention; + } + + private ChatResponse internalCall(Prompt prompt, ChatResponse previousChatResponse) { + ChatModelObservationContext observationContext = ChatModelObservationContext.builder() + .prompt(prompt) + .provider(AiProvider.ALIBABA.value()) + .requestOptions(prompt.getOptions()) + .build(); + + ChatResponse response = ChatModelObservationDocumentation.CHAT_MODEL_OPERATION + .observation(this.observationConvention, DEFAULT_OBSERVATION_CONVENTION, () -> observationContext, + this.observationRegistry) + .observe(() -> { + ChatResponse chatResponse = qwenApi.call(prompt, previousChatResponse); + observationContext.setResponse(chatResponse); + return chatResponse; + }); + + if (toolExecutionEligibilityPredicate.isToolExecutionRequired(prompt.getOptions(), response)) { + 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 internalCall(new Prompt(toolExecutionResult.conversationHistory(), prompt.getOptions()), + response); + } + } + + return response; + } + + private Flux internalStream(Prompt prompt, ChatResponse previousChatResponse) { + return Flux.deferContextual(contextView -> { + final ChatModelObservationContext observationContext = ChatModelObservationContext.builder() + .prompt(prompt) + .provider(AiProvider.ALIBABA.value()) + .requestOptions(prompt.getOptions()) + .build(); + + Observation observation = ChatModelObservationDocumentation.CHAT_MODEL_OPERATION.observation( + this.observationConvention, DEFAULT_OBSERVATION_CONVENTION, () -> observationContext, + this.observationRegistry); + + observation.parentObservation(contextView.getOrDefault(ObservationThreadLocalAccessor.KEY, null)).start(); + + // @formatter:off + Flux chatResponse = this.qwenApi.streamCall(prompt, previousChatResponse) + .flatMap(response -> { + if (this.toolExecutionEligibilityPredicate.isToolExecutionRequired(prompt.getOptions(), response)) { + return Flux.defer(() -> { + 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()), + response); + } + }).subscribeOn(Schedulers.boundedElastic()); + } + else { + return Flux.just(response); + } + }) + .doOnError(observation::error) + .doFinally(s -> observation.stop()) + .contextWrite(ctx -> ctx.put(ObservationThreadLocalAccessor.KEY, observation)); + // @formatter:on + + return new MessageAggregator().aggregate(chatResponse, observationContext::setResponse); + }); + } + + private Prompt buildRequestPrompt(Prompt prompt) { + // process runtime options + QwenChatOptions runtimeOptions = null; + if (prompt.getOptions() != null) { + if (prompt.getOptions() instanceof ToolCallingChatOptions toolCallingChatOptions) { + runtimeOptions = ModelOptionsUtils.copyToTarget(toolCallingChatOptions, ToolCallingChatOptions.class, + QwenChatOptions.class); + } + else if (prompt.getOptions() instanceof FunctionCallingOptions functionCallingOptions) { + runtimeOptions = ModelOptionsUtils.copyToTarget(functionCallingOptions, FunctionCallingOptions.class, + QwenChatOptions.class); + } + else { + runtimeOptions = ModelOptionsUtils.copyToTarget(prompt.getOptions(), ChatOptions.class, + QwenChatOptions.class); + } + } + + QwenChatOptions requestOptions = QwenChatOptions.fromOptions(this.defaultOptions).overrideWith(runtimeOptions); + + ToolCallingChatOptions.validateToolCallbacks(requestOptions.getToolCallbacks()); + + return new Prompt(prompt.getInstructions(), requestOptions); + } + + public static final class Builder { + + private QwenApi qwenApi; + + private QwenChatOptions defaultOptions = QwenChatOptions.builder().model(QwenModel.QWEN_MAX.getName()).build(); + + private ToolCallingManager toolCallingManager; + + private ToolExecutionEligibilityPredicate toolExecutionEligibilityPredicate = new DefaultToolExecutionEligibilityPredicate(); + + private ObservationRegistry observationRegistry = ObservationRegistry.NOOP; + + private Builder() { + } + + public Builder qwenApi(QwenApi qwenApi) { + this.qwenApi = qwenApi; + return this; + } + + public Builder defaultOptions(QwenChatOptions defaultOptions) { + this.defaultOptions = defaultOptions; + return this; + } + + public Builder toolCallingManager(ToolCallingManager toolCallingManager) { + this.toolCallingManager = toolCallingManager; + return this; + } + + public Builder toolExecutionEligibilityPredicate( + ToolExecutionEligibilityPredicate toolExecutionEligibilityPredicate) { + this.toolExecutionEligibilityPredicate = toolExecutionEligibilityPredicate; + return this; + } + + public Builder observationRegistry(ObservationRegistry observationRegistry) { + this.observationRegistry = observationRegistry; + return this; + } + + public QwenChatModel build() { + return new QwenChatModel(this.qwenApi, this.defaultOptions, this.toolCallingManager, + this.observationRegistry, this.toolExecutionEligibilityPredicate); + } + + } + +} diff --git a/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/QwenChatOptions.java b/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/QwenChatOptions.java new file mode 100644 index 00000000000..1b139e36ce5 --- /dev/null +++ b/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/QwenChatOptions.java @@ -0,0 +1,799 @@ +package org.springframework.ai.qwen; + +import com.alibaba.dashscope.common.ResponseFormat; +import org.springframework.ai.model.function.FunctionCallback; +import org.springframework.ai.model.tool.ToolCallingChatOptions; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.springframework.ai.qwen.api.QwenApiHelper.copyIfNotNull; +import static org.springframework.ai.qwen.api.QwenApiHelper.getOrDefault; + +/** + * Options for the OpenAI Chat API. + * + * @author Peng Jiang + * @since 1.0.0 + */ +@SuppressWarnings("LombokGetterMayBeUsed") +public class QwenChatOptions implements ToolCallingChatOptions { + + /** + * ID of the model to use. + */ + private String model; + + /** + * Number between -2.0 and 2.0. Positive values penalize new tokens based on their + * existing frequency in the text so far, decreasing the model's likelihood to repeat + * the same line verbatim. + */ + private Double frequencyPenalty; + + /** + * The maximum number of tokens to generate in the chat completion. The total length + * of input tokens and generated tokens is limited by the model's context length. + */ + private Integer maxTokens; + + /** + * Number between -2.0 and 2.0. Positive values penalize new tokens based on whether + * they appear in the text so far, increasing the model's likelihood to talk about new + * topics. + */ + private Double presencePenalty; + + /** + * An object specifying the format that the model must output. Setting to { "type": + * "json_object" } enables JSON mode, which guarantees the message the model generates + * is valid JSON. + */ + private ResponseFormat responseFormat; + + /** + * If specified, our system will make a best effort to sample deterministically, such + * that repeated requests with the same seed and parameters should return the same + * result. Determinism is not guaranteed, and you should refer to the + * system_fingerprint response parameter to monitor changes in the backend. + */ + private Integer seed; + + /** + * Up to 4 sequences where the API will stop generating further tokens. + */ + private List stopSequences; + + /** + * What sampling temperature to use, between 0 and 1. Higher values like 0.8 will make + * the output more random, while lower values like 0.2 will make it more focused and + * deterministic. We generally recommend altering this or top_p but not both. + */ + private Double temperature; + + /** + * An alternative to sampling with temperature, called nucleus sampling, where the + * model considers the results of the tokens with top_p probability mass. So 0.1 means + * only the tokens comprising the top 10% probability mass are considered. We + * generally recommend altering this or temperature but not both. + */ + private Double topP; + + /** + * The size of the candidate set for sampling during the generation process. For + * example, when the value is 50, only the 50 tokens with the highest scores in a + * single generation will form the candidate set for random sampling. The larger the + * value, the higher the randomness of the generation; the smaller the value,the + * higher the certainty of the generation. When the value is None or when top_k is + * greater than 100, it means that the top_k strategy is not enabled, and only the + * top_p strategy is effective. The value needs to be greater than or equal to 0. + */ + private Integer topK; + + /** + * Collection of {@link ToolCallback}s to be used for tool calling in the chat + * completion requests. + */ + private List toolCallbacks; + + /** + * Collection of tool names to be resolved at runtime and used for tool calling in the + * chat completion requests. + */ + private Set toolNames; + + /** + * Whether to enable the tool execution lifecycle internally in ChatModel. + */ + private Boolean internalToolExecutionEnabled; + + private Map toolContext; + + /** + * Controls which (if any) function is called by the model. none means the model will + * not call a function and instead generates a message. auto means the model can pick + * between generating a message or calling a function. Specifying a particular + * function via {"type: "function", "function": {"name": "my_function"}} forces the + * model to call that function. none is the default when no functions are present. + * auto is the default if functions are present. + */ + private Object toolChoice; + + /** + * Whether the model should use internet search results for reference when generating + * text. + */ + private Boolean enableSearch; + + /** + * The strategy for network search. Only takes effect when enableSearch is true. + */ + private SearchOptions searchOptions; + + /** + * The translation parameters you need to configure when you use the translation + * models. + */ + private TranslationOptions translationOptions; + + /** + * Whether to increase the default token limit for input images. The default token + * limit for input images is 1280. When configured to true, the token limit for input + * images is 16384. Default value is false. + */ + private Boolean vlHighResolutionImages; + + /** + * Whether the model is a multimodal model (whether it supports multimodal input). If + * not specified, it will be judged based on the model name when called, but these + * judgments may not keep up with the latest situation. + */ + private Boolean multimodalModel; + + /** + * Whether the model supports incremental output in the streaming output mode. This + * parameter is used to assist QwenChatModel in providing incremental output in stream + * mode. If not specified, it will be judged based on the model name when called, but + * these judgments may not keep up with the latest situation. + */ + private Boolean supportIncrementalOutput; + + /** + * User-defined parameters. They may have special effects on some special models. + */ + private Map custom; + + private QwenChatOptions(Builder builder) { + this.model = builder.model; + this.frequencyPenalty = builder.frequencyPenalty; + this.maxTokens = builder.maxTokens; + this.presencePenalty = builder.presencePenalty; + this.responseFormat = builder.responseFormat; + this.seed = builder.seed; + this.stopSequences = builder.stopSequences; + this.temperature = builder.temperature; + this.topP = builder.topP; + this.topK = builder.topK; + this.toolCallbacks = builder.toolCallbacks; + this.toolNames = builder.toolNames; + this.internalToolExecutionEnabled = builder.internalToolExecutionEnabled; + this.toolContext = builder.toolContext; + this.toolChoice = builder.toolChoice; + this.enableSearch = builder.enableSearch; + this.searchOptions = builder.searchOptions; + this.translationOptions = builder.translationOptions; + this.vlHighResolutionImages = builder.vlHighResolutionImages; + this.multimodalModel = builder.multimodalModel; + this.custom = builder.custom; + } + + @Override + public String getModel() { + return model; + } + + public void setModel(String model) { + this.model = model; + } + + @Override + public Double getFrequencyPenalty() { + return frequencyPenalty; + } + + public void setFrequencyPenalty(Double frequencyPenalty) { + this.frequencyPenalty = frequencyPenalty; + } + + @Override + public Integer getMaxTokens() { + return maxTokens; + } + + public void setMaxTokens(Integer maxTokens) { + this.maxTokens = maxTokens; + } + + @Override + public Double getPresencePenalty() { + return presencePenalty; + } + + public void setPresencePenalty(Double presencePenalty) { + this.presencePenalty = presencePenalty; + } + + public ResponseFormat getResponseFormat() { + return responseFormat; + } + + public void setResponseFormat(ResponseFormat responseFormat) { + this.responseFormat = responseFormat; + } + + public Integer getSeed() { + return seed; + } + + public void setSeed(Integer seed) { + this.seed = seed; + } + + public List getStopSequences() { + return stopSequences; + } + + public void setStopSequences(List stopSequences) { + this.stopSequences = stopSequences; + } + + @Override + public Double getTemperature() { + return temperature; + } + + public void setTemperature(Double temperature) { + this.temperature = temperature; + } + + @Override + public Double getTopP() { + return topP; + } + + @Override + public List getToolCallbacks() { + return this.toolCallbacks; + } + + @Override + public void setToolCallbacks(List toolCallbacks) { + Assert.notNull(toolCallbacks, "toolCallbacks cannot be null"); + Assert.noNullElements(toolCallbacks, "toolCallbacks cannot contain null elements"); + this.toolCallbacks = toolCallbacks; + } + + @Override + @Deprecated + public List getFunctionCallbacks() { + return this.getToolCallbacks(); + } + + @Override + @Deprecated + public void setFunctionCallbacks(List functionCallbacks) { + this.setToolCallbacks(functionCallbacks); + } + + @Override + public Set getToolNames() { + return this.toolNames; + } + + @Override + public void setToolNames(Set 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 + @Deprecated + public Set getFunctions() { + return this.getToolNames(); + } + + @Override + @Deprecated + public void setFunctions(Set functionNames) { + this.setToolNames(functionNames); + } + + @Override + @Nullable + public Boolean isInternalToolExecutionEnabled() { + return internalToolExecutionEnabled; + } + + @Override + public void setInternalToolExecutionEnabled(@Nullable Boolean internalToolExecutionEnabled) { + this.internalToolExecutionEnabled = internalToolExecutionEnabled; + } + + public void setTopP(Double topP) { + this.topP = topP; + } + + @Override + public Integer getTopK() { + return topK; + } + + public void setTopK(Integer topK) { + this.topK = topK; + } + + @Override + public Map getToolContext() { + return toolContext; + } + + @Override + public void setToolContext(Map toolContext) { + this.toolContext = toolContext; + } + + public Object getToolChoice() { + return toolChoice; + } + + public void setToolChoice(Object toolChoice) { + this.toolChoice = toolChoice; + } + + public Boolean isEnableSearch() { + return enableSearch; + } + + public void setEnableSearch(Boolean enableSearch) { + this.enableSearch = enableSearch; + } + + public SearchOptions getSearchOptions() { + return searchOptions; + } + + public void setSearchOptions(SearchOptions searchOptions) { + this.searchOptions = searchOptions; + } + + public TranslationOptions getTranslationOptions() { + return translationOptions; + } + + public void setTranslationOptions(TranslationOptions translationOptions) { + this.translationOptions = translationOptions; + } + + public Boolean getVlHighResolutionImages() { + return vlHighResolutionImages; + } + + public void setVlHighResolutionImages(Boolean vlHighResolutionImages) { + this.vlHighResolutionImages = vlHighResolutionImages; + } + + public Boolean isMultimodalModel() { + return multimodalModel; + } + + public void setMultimodalModel(Boolean multimodalModel) { + this.multimodalModel = multimodalModel; + } + + public Boolean getSupportIncrementalOutput() { + return supportIncrementalOutput; + } + + public void setSupportIncrementalOutput(Boolean supportIncrementalOutput) { + this.supportIncrementalOutput = supportIncrementalOutput; + } + + public Map getCustom() { + return custom; + } + + public void setCustom(Map custom) { + this.custom = custom; + } + + @Override + public QwenChatOptions copy() { + return fromOptions(this); + } + + public static QwenChatOptions fromOptions(QwenChatOptions fromOptions) { + return QwenChatOptions.builder().overrideWith(fromOptions).build(); + } + + public QwenChatOptions overrideWith(QwenChatOptions that) { + return QwenChatOptions.builder().overrideWith(this).overrideWith(that).build(); + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private String model; + + private Double frequencyPenalty; + + private Integer maxTokens; + + private Double presencePenalty; + + private ResponseFormat responseFormat; + + private Integer seed; + + private List stopSequences = new ArrayList<>(); + + private Double temperature; + + private Double topP; + + private Integer topK; + + private List toolCallbacks = new ArrayList<>(); + + private Set toolNames = new HashSet<>(); + + private Boolean internalToolExecutionEnabled; + + private Map toolContext = new HashMap<>(); + + private Object toolChoice; + + private Boolean enableSearch; + + private SearchOptions searchOptions; + + private TranslationOptions translationOptions; + + private Boolean vlHighResolutionImages; + + private Boolean multimodalModel; + + private Boolean supportIncrementalOutput; + + private Map custom; + + public Builder model(String model) { + this.model = model; + return this; + } + + public Builder frequencyPenalty(Double frequencyPenalty) { + this.frequencyPenalty = frequencyPenalty; + return this; + } + + public Builder maxTokens(Integer maxTokens) { + this.maxTokens = maxTokens; + return this; + } + + public Builder presencePenalty(Double presencePenalty) { + this.presencePenalty = presencePenalty; + return this; + } + + public Builder responseFormat(ResponseFormat responseFormat) { + this.responseFormat = responseFormat; + return this; + } + + public Builder seed(Integer seed) { + this.seed = seed; + return this; + } + + public Builder stopSequences(List stopSequences) { + this.stopSequences = stopSequences; + return this; + } + + public Builder temperature(Double temperature) { + this.temperature = temperature; + return this; + } + + public Builder topP(Double topP) { + this.topP = topP; + return this; + } + + public Builder topK(Integer topK) { + this.topK = topK; + return this; + } + + public Builder toolCallbacks(List toolCallbacks) { + this.toolCallbacks = toolCallbacks; + return this; + } + + public Builder toolNames(Set toolNames) { + this.toolNames = toolNames; + return this; + } + + public Builder internalToolExecutionEnabled(Boolean enabled) { + this.internalToolExecutionEnabled = enabled; + return this; + } + + public Builder toolContext(Map toolContext) { + this.toolContext = toolContext; + return this; + } + + public Builder toolChoice(Object toolChoice) { + this.toolChoice = toolChoice; + return this; + } + + public Builder enableSearch(Boolean enableSearch) { + this.enableSearch = enableSearch; + return this; + } + + public Builder searchOptions(SearchOptions searchOptions) { + this.searchOptions = searchOptions; + return this; + } + + public Builder translationOptions(TranslationOptions translationOptions) { + this.translationOptions = translationOptions; + return this; + } + + public Builder vlHighResolutionImages(Boolean vlHighResolutionImages) { + this.vlHighResolutionImages = vlHighResolutionImages; + return this; + } + + public Builder isMultimodalModel(Boolean isMultimodalModel) { + this.multimodalModel = isMultimodalModel; + return this; + } + + public Builder supportIncrementalOutput(Boolean supportIncrementalOutput) { + this.supportIncrementalOutput = supportIncrementalOutput; + return this; + } + + public Builder custom(Map custom) { + this.custom = custom; + return this; + } + + public Builder overrideWith(QwenChatOptions fromOptions) { + if (fromOptions == null) { + return this; + } + + this.model(getOrDefault(fromOptions.getModel(), this.model)); + this.frequencyPenalty(getOrDefault(fromOptions.getFrequencyPenalty(), this.frequencyPenalty)); + this.maxTokens(getOrDefault(fromOptions.getMaxTokens(), this.maxTokens)); + this.presencePenalty(getOrDefault(fromOptions.getPresencePenalty(), this.presencePenalty)); + this.responseFormat(getOrDefault(fromOptions.getResponseFormat(), this.responseFormat)); + this.seed(getOrDefault(fromOptions.getSeed(), this.seed)); + this.stopSequences(copyIfNotNull(getOrDefault(fromOptions.getStopSequences(), this.stopSequences))); + this.temperature(getOrDefault(fromOptions.getTemperature(), this.temperature)); + this.topP(getOrDefault(fromOptions.getTopP(), this.topP)); + this.topK(getOrDefault(fromOptions.getTopK(), this.topK)); + this.toolCallbacks(copyIfNotNull(getOrDefault(fromOptions.getToolCallbacks(), this.toolCallbacks))); + this.toolNames(copyIfNotNull(getOrDefault(fromOptions.getToolNames(), this.toolNames))); + this.internalToolExecutionEnabled( + getOrDefault(fromOptions.isInternalToolExecutionEnabled(), this.internalToolExecutionEnabled)); + this.toolContext(getOrDefault(fromOptions.getToolContext(), this.toolContext)); + this.toolChoice(getOrDefault(fromOptions.getToolChoice(), this.toolChoice)); + this.enableSearch(getOrDefault(fromOptions.isEnableSearch(), this.enableSearch)); + this.searchOptions(getOrDefault(fromOptions.getSearchOptions(), this.searchOptions)); + this.translationOptions(getOrDefault(fromOptions.getTranslationOptions(), this.translationOptions)); + this.vlHighResolutionImages( + getOrDefault(fromOptions.getVlHighResolutionImages(), this.vlHighResolutionImages)); + this.isMultimodalModel(getOrDefault(fromOptions.isMultimodalModel(), this.multimodalModel)); + this.supportIncrementalOutput( + getOrDefault(fromOptions.getSupportIncrementalOutput(), this.supportIncrementalOutput)); + this.custom(copyIfNotNull(getOrDefault(fromOptions.getCustom(), this.custom))); + return this; + } + + public QwenChatOptions build() { + return new QwenChatOptions(this); + } + + } + + /** + * The strategy for network search. + * + * @param enableSource Whether to display the searched information in the returned + * results. Default value is false. + * @param enableCitation Whether to enable the [1] or [ref_1] style superscript + * annotation function. This function takes effect only when enable_source is true. + * Default value is false. + * @param citationFormat Subscript style. Only available when enable_citation is true. + * Supported styles: “[]” and “[ref_]”. Default value is “[]”. + * @param forcedSearch Whether to force search to start. + * @param searchStrategy The amount of Internet information searched. Supported + * values: “standard” and “pro”. Default value is “standard”. + */ + public record SearchOptions(Boolean enableSource, Boolean enableCitation, String citationFormat, + Boolean forcedSearch, String searchStrategy) { + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private Boolean enableSource; + + private Boolean enableCitation; + + private String citationFormat; + + private Boolean forcedSearch; + + private String searchStrategy; + + public Builder enableSource(Boolean enableSource) { + this.enableSource = enableSource; + return this; + } + + public Builder enableCitation(Boolean enableCitation) { + this.enableCitation = enableCitation; + return this; + } + + public Builder citationFormat(String citationFormat) { + this.citationFormat = citationFormat; + return this; + } + + public Builder forcedSearch(Boolean forcedSearch) { + this.forcedSearch = forcedSearch; + return this; + } + + public Builder searchStrategy(String searchStrategy) { + this.searchStrategy = searchStrategy; + return this; + } + + public SearchOptions build() { + return new SearchOptions(enableSource, enableCitation, citationFormat, forcedSearch, searchStrategy); + } + + } + } + + /** + * The translation parameters you need to configure when you use the translation + * models. + * + * @param sourceLang The full English name of the source language.For more + * information, see Supported + * Languages. You can set source_lang to "auto" and the model will automatically + * determine the language of the input text. + * @param targetLang The full English name of the target language.For more + * information, see Supported + * Languages. + * @param terms An array of terms that needs to be set when using the + * term-intervention-translation feature. + * @param tmList The translation memory array that needs to be set when using the + * translation-memory feature. + * @param domains The domain prompt statement needs to be set when using the + * domain-prompt feature. + */ + public record TranslationOptions(String sourceLang, String targetLang, List terms, + List tmList, String domains) { + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private String sourceLang; + + private String targetLang; + + private List terms; + + private List tmLists; + + private String domains; + + public Builder sourceLang(String sourceLang) { + this.sourceLang = sourceLang; + return this; + } + + public Builder targetLang(String targetLang) { + this.targetLang = targetLang; + return this; + } + + public Builder terms(List terms) { + this.terms = terms; + return this; + } + + public Builder tmLists(List tmLists) { + this.tmLists = tmLists; + return this; + } + + public Builder domains(String domains) { + this.domains = domains; + return this; + } + + public TranslationOptions build() { + return new TranslationOptions(sourceLang, targetLang, terms, tmLists, domains); + } + + } + } + + /** + * @param source The term in the source language. + * @param target The term in the target language. + */ + public record TranslationOptionTerm(String source, String target) { + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private String source; + + private String target; + + public Builder source(String source) { + this.source = source; + return this; + } + + public Builder target(String target) { + this.target = target; + return this; + } + + public TranslationOptionTerm build() { + return new TranslationOptionTerm(source, target); + } + + } + } + +} diff --git a/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/aot/QwenRuntimeHints.java b/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/aot/QwenRuntimeHints.java new file mode 100644 index 00000000000..e51aeb39bdf --- /dev/null +++ b/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/aot/QwenRuntimeHints.java @@ -0,0 +1,21 @@ +package org.springframework.ai.qwen.aot; + +import org.springframework.ai.aot.AiRuntimeHints; +import org.springframework.aot.hint.MemberCategory; +import org.springframework.aot.hint.RuntimeHints; +import org.springframework.aot.hint.RuntimeHintsRegistrar; +import org.springframework.lang.NonNull; +import org.springframework.lang.Nullable; + +public class QwenRuntimeHints implements RuntimeHintsRegistrar { + + @Override + public void registerHints(@NonNull RuntimeHints hints, @Nullable ClassLoader classLoader) { + var mcs = MemberCategory.values(); + AiRuntimeHints + .findClassesInPackage(com.alibaba.dashscope.Version.class.getPackageName(), + (metadataReader, metadataReaderFactory) -> true) + .forEach(clazz -> hints.reflection().registerType(clazz, mcs)); + } + +} diff --git a/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/api/QwenApi.java b/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/api/QwenApi.java new file mode 100644 index 00000000000..6c20d57f511 --- /dev/null +++ b/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/api/QwenApi.java @@ -0,0 +1,329 @@ +package org.springframework.ai.qwen.api; + +import com.alibaba.dashscope.aigc.generation.GenerationOutput; +import com.alibaba.dashscope.aigc.generation.GenerationParam; +import com.alibaba.dashscope.aigc.generation.GenerationResult; +import com.alibaba.dashscope.aigc.multimodalconversation.MultiModalConversation; +import com.alibaba.dashscope.aigc.multimodalconversation.MultiModalConversationOutput; +import com.alibaba.dashscope.aigc.multimodalconversation.MultiModalConversationParam; +import com.alibaba.dashscope.aigc.multimodalconversation.MultiModalConversationResult; +import com.alibaba.dashscope.common.MultiModalMessage; +import com.alibaba.dashscope.exception.InputRequiredException; +import com.alibaba.dashscope.exception.NoApiKeyException; +import com.alibaba.dashscope.exception.UploadFileException; +import com.alibaba.dashscope.protocol.Protocol; +import org.springframework.ai.chat.metadata.ChatResponseMetadata; +import org.springframework.ai.chat.metadata.Usage; +import org.springframework.ai.chat.metadata.UsageUtils; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.model.Generation; +import org.springframework.ai.chat.prompt.ChatOptions; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.model.ApiKey; +import org.springframework.ai.model.SimpleApiKey; +import org.springframework.ai.qwen.QwenChatOptions; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; + +import java.util.List; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; + +import static org.springframework.ai.qwen.api.QwenApiHelper.toQwenSearchInfo; +import static org.springframework.ai.qwen.api.QwenApiHelper.defaultUsageFrom; +import static org.springframework.ai.qwen.api.QwenApiHelper.generationsFrom; +import static org.springframework.ai.qwen.api.QwenApiHelper.getOrDefault; +import static org.springframework.ai.qwen.api.QwenApiHelper.isMultimodalModelName; +import static org.springframework.ai.qwen.api.QwenApiHelper.isStreamingDone; +import static org.springframework.ai.qwen.api.QwenApiHelper.isStreamingToolCall; +import static org.springframework.ai.qwen.api.QwenApiHelper.isSupportingIncrementalOutputModelName; +import static org.springframework.ai.qwen.api.QwenApiHelper.newGenerationResult; +import static org.springframework.ai.qwen.api.QwenApiHelper.toGenerationParam; +import static org.springframework.ai.qwen.api.QwenApiHelper.toMultiModalConversationParam; +import static org.springframework.ai.qwen.api.QwenApiHelper.toQwenResultCallback; + +public class QwenApi { + + private final String apiKey; + + private final com.alibaba.dashscope.aigc.generation.Generation generation; + + private final com.alibaba.dashscope.aigc.multimodalconversation.MultiModalConversation conv; + + /** + * Some models support deeply customized parameters. Here is a way to intervene in the + * request parameters of the qwen models at runtime. + */ + private Consumer> generationParamCustomizer = p -> { + }; + + /** + * Some models support deeply customized parameters. Here is a way to intervene in the + * request parameters of the qwen multimodal-models at runtime. + */ + private Consumer> multimodalConversationParamCustomizer = p -> { + }; + + public QwenApi(String baseUrl, ApiKey apiKey) { + if (!StringUtils.hasText(baseUrl)) { + this.conv = new MultiModalConversation(); + this.generation = new com.alibaba.dashscope.aigc.generation.Generation(); + } + else if (baseUrl.startsWith("wss://")) { + this.conv = new MultiModalConversation(Protocol.WEBSOCKET.getValue(), baseUrl); + this.generation = new com.alibaba.dashscope.aigc.generation.Generation(Protocol.WEBSOCKET.getValue(), + baseUrl); + } + else { + this.conv = new MultiModalConversation(Protocol.HTTP.getValue(), baseUrl); + this.generation = new com.alibaba.dashscope.aigc.generation.Generation(Protocol.HTTP.getValue(), baseUrl); + } + + this.apiKey = apiKey.getValue(); + } + + public static Builder builder() { + return new Builder(); + } + + public ChatResponse call(Prompt prompt, ChatResponse previousChatResponse) { + return isMultimodalModel(prompt) ? callMultimodalModel(prompt, previousChatResponse) + : callNonMultimodalModel(prompt, previousChatResponse); + } + + private ChatResponse callNonMultimodalModel(Prompt prompt, ChatResponse previousChatResponse) { + GenerationParam param = toGenerationParam(apiKey, prompt, false, generationParamCustomizer); + + try { + GenerationResult result = generation.call(param); + List generations = generationsFrom(result); + Usage currentUsage = defaultUsageFrom(result.getUsage()); + Usage accumulatedUsage = UsageUtils.getCumulativeUsage(currentUsage, previousChatResponse); + ChatResponseMetadata.Builder metadataBuilder = ChatResponseMetadata.builder() + .id(result.getRequestId()) + .usage(accumulatedUsage) + .model(prompt.getOptions().getModel()); + if (result.getOutput().getSearchInfo() != null) { + metadataBuilder.keyValue("searchInfo", toQwenSearchInfo(result.getOutput().getSearchInfo())); + } + return new ChatResponse(generations, metadataBuilder.build()); + } + catch (NoApiKeyException | InputRequiredException e) { + throw new IllegalArgumentException(e); + } + } + + private ChatResponse callMultimodalModel(Prompt prompt, ChatResponse previousChatResponse) { + MultiModalConversationParam param = toMultiModalConversationParam(apiKey, prompt, false, + multimodalConversationParamCustomizer); + + try { + MultiModalConversationResult result = conv.call(param); + List generations = generationsFrom(result); + Usage currentUsage = defaultUsageFrom(result.getUsage()); + Usage accumulatedUsage = UsageUtils.getCumulativeUsage(currentUsage, previousChatResponse); + ChatResponseMetadata metadata = ChatResponseMetadata.builder() + .id(result.getRequestId()) + .usage(accumulatedUsage) + .model(prompt.getOptions().getModel()) + .build(); + return new ChatResponse(generations, metadata); + } + catch (NoApiKeyException e) { + throw new IllegalArgumentException(e); + } + catch (UploadFileException e) { + throw new IllegalStateException(e); + } + } + + public Flux streamCall(Prompt prompt, ChatResponse previousChatResponse) { + return isMultimodalModel(prompt) ? streamCallMultimodalModel(prompt, previousChatResponse) + : streamCallNonMultimodalModel(prompt, previousChatResponse); + } + + private Flux streamCallNonMultimodalModel(Prompt prompt, ChatResponse previousChatResponse) { + boolean incrementalOutput = supportIncrementalOutput(prompt); + GenerationParam param = toGenerationParam(apiKey, prompt, incrementalOutput, generationParamCustomizer); + StringBuilder generatedContent = new StringBuilder(); + Sinks.Many sink = Sinks.many().multicast().onBackpressureBuffer(); + AtomicBoolean isInsideTool = new AtomicBoolean(false); + + try { + generation.streamCall(param, toQwenResultCallback(sink)); + + return sink.asFlux().map(result -> { + if (isStreamingToolCall(result)) { + isInsideTool.set(true); + } + if (!incrementalOutput) { + // unified into incremental output mode + Optional.of(result) + .map(GenerationResult::getOutput) + .map(GenerationOutput::getChoices) + .filter(choices -> !choices.isEmpty()) + .map(choices -> choices.get(0)) + .map(GenerationOutput.Choice::getMessage) + .filter(message -> StringUtils.hasText(message.getContent())) + .ifPresent(message -> { + String partialContent = message.getContent().substring(generatedContent.length()); + generatedContent.append(partialContent); + message.setContent(partialContent); + }); + } + return result; + }).windowUntil(result -> { + if (isInsideTool.get() && isStreamingDone(result)) { + isInsideTool.set(false); + return true; + } + return !isInsideTool.get(); + }).concatMapIterable(window -> { + Mono monoChunk = window.reduce(newGenerationResult(), QwenApiHelper::mergeResult); + return List.of(monoChunk); + }).flatMap(mono -> mono).map(result -> { + List generations = generationsFrom(result); + Usage currentUsage = defaultUsageFrom(result.getUsage()); + Usage accumulatedUsage = UsageUtils.getCumulativeUsage(currentUsage, previousChatResponse); + ChatResponseMetadata.Builder metadataBuilder = ChatResponseMetadata.builder() + .id(result.getRequestId()) + .usage(accumulatedUsage) + .model(prompt.getOptions().getModel()); + if (result.getOutput().getSearchInfo() != null) { + metadataBuilder.keyValue("searchInfo", toQwenSearchInfo(result.getOutput().getSearchInfo())); + } + return new ChatResponse(generations, metadataBuilder.build()); + }); + + } + catch (NoApiKeyException | InputRequiredException e) { + throw new IllegalArgumentException(e); + } + } + + private Flux streamCallMultimodalModel(Prompt prompt, ChatResponse previousChatResponse) { + boolean incrementalOutput = supportIncrementalOutput(prompt); + MultiModalConversationParam param = toMultiModalConversationParam(apiKey, prompt, incrementalOutput, + multimodalConversationParamCustomizer); + + StringBuilder generatedContent = new StringBuilder(); + Sinks.Many sink = Sinks.many().multicast().onBackpressureBuffer(); + + try { + // note: multimodal models do not support toolcalls + conv.streamCall(param, toQwenResultCallback(sink)); + + return sink.asFlux().map(result -> { + if (!incrementalOutput) { + // unified into incremental output mode + Optional.of(result) + .map(MultiModalConversationResult::getOutput) + .map(MultiModalConversationOutput::getChoices) + .filter(choices -> !choices.isEmpty()) + .map(choices -> choices.get(0)) + .map(MultiModalConversationOutput.Choice::getMessage) + .map(MultiModalMessage::getContent) + .filter(contents -> !contents.isEmpty()) + .map(contents -> contents.get(0)) + .filter(content -> StringUtils.hasText((String) content.get("text"))) + .ifPresent(content -> { + String textContent = (String) content.get("text"); + String partialContent = textContent.substring(generatedContent.length()); + generatedContent.append(partialContent); + content.put("text", partialContent); + }); + } + return result; + }).map(result -> { + List generations = generationsFrom(result); + Usage currentUsage = defaultUsageFrom(result.getUsage()); + Usage accumulatedUsage = UsageUtils.getCumulativeUsage(currentUsage, previousChatResponse); + ChatResponseMetadata metadata = ChatResponseMetadata.builder() + .id(result.getRequestId()) + .usage(accumulatedUsage) + .model(prompt.getOptions().getModel()) + .build(); + return new ChatResponse(generations, metadata); + }); + + } + catch (NoApiKeyException | InputRequiredException e) { + throw new IllegalArgumentException(e); + } + catch (UploadFileException e) { + throw new IllegalStateException(e); + } + } + + boolean isMultimodalModel(Prompt prompt) { + ChatOptions options = prompt.getOptions(); + if (!(options instanceof QwenChatOptions)) { + throw new IllegalArgumentException("options should be an instance of QwenChatOption"); + } + + String modelName = options.getModel(); + Boolean isMultimodalModel = ((QwenChatOptions) options).isMultimodalModel(); + isMultimodalModel = getOrDefault(isMultimodalModel, isMultimodalModelName(modelName)); + + return Boolean.TRUE.equals(isMultimodalModel); + } + + boolean supportIncrementalOutput(Prompt prompt) { + ChatOptions options = prompt.getOptions(); + if (!(options instanceof QwenChatOptions)) { + throw new IllegalArgumentException("options should be an instance of QwenChatOption"); + } + + String modelName = options.getModel(); + Boolean supportIncrementalOutput = ((QwenChatOptions) options).getSupportIncrementalOutput(); + supportIncrementalOutput = getOrDefault(supportIncrementalOutput, + isSupportingIncrementalOutputModelName(modelName)); + + return Boolean.TRUE.equals(supportIncrementalOutput); + } + + public void setGenerationParamCustomizer( + Consumer> generationParamCustomizer) { + this.generationParamCustomizer = generationParamCustomizer; + } + + public void setMultimodalConversationParamCustomizer( + Consumer> multimodalConversationParamCustomizer) { + this.multimodalConversationParamCustomizer = multimodalConversationParamCustomizer; + } + + public static class Builder { + + private String baseUrl; + + private ApiKey apiKey; + + public Builder baseUrl(String baseUrl) { + this.baseUrl = baseUrl; + return this; + } + + public Builder apiKey(ApiKey apiKey) { + Assert.notNull(apiKey, "apiKey cannot be null"); + this.apiKey = apiKey; + return this; + } + + public Builder apiKey(String simpleApiKey) { + Assert.notNull(simpleApiKey, "simpleApiKey cannot be null"); + this.apiKey = new SimpleApiKey(simpleApiKey); + return this; + } + + public QwenApi build() { + Assert.notNull(this.apiKey, "apiKey must be set"); + return new QwenApi(this.baseUrl, this.apiKey); + } + + } + +} diff --git a/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/api/QwenApiHelper.java b/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/api/QwenApiHelper.java new file mode 100644 index 00000000000..cdf83629970 --- /dev/null +++ b/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/api/QwenApiHelper.java @@ -0,0 +1,1013 @@ +package org.springframework.ai.qwen.api; + +import com.alibaba.dashscope.aigc.generation.GenerationOutput; +import com.alibaba.dashscope.aigc.generation.GenerationParam; +import com.alibaba.dashscope.aigc.generation.GenerationResult; +import com.alibaba.dashscope.aigc.generation.GenerationUsage; +import com.alibaba.dashscope.aigc.generation.SearchInfo; +import com.alibaba.dashscope.aigc.multimodalconversation.MultiModalConversationOutput; +import com.alibaba.dashscope.aigc.multimodalconversation.MultiModalConversationParam; +import com.alibaba.dashscope.aigc.multimodalconversation.MultiModalConversationResult; +import com.alibaba.dashscope.aigc.multimodalconversation.MultiModalConversationUsage; +import com.alibaba.dashscope.common.DashScopeResult; +import com.alibaba.dashscope.common.MessageContentBase; +import com.alibaba.dashscope.common.MessageContentImageURL; +import com.alibaba.dashscope.common.MessageContentText; +import com.alibaba.dashscope.common.MultiModalMessage; +import com.alibaba.dashscope.common.ResultCallback; +import com.alibaba.dashscope.common.Role; +import com.alibaba.dashscope.tools.FunctionDefinition; +import com.alibaba.dashscope.tools.ToolBase; +import com.alibaba.dashscope.tools.ToolCallBase; +import com.alibaba.dashscope.tools.ToolCallFunction; +import com.alibaba.dashscope.tools.ToolFunction; +import com.alibaba.dashscope.tools.codeinterpretertool.ToolCallCodeInterpreter; +import com.alibaba.dashscope.tools.search.ToolCallQuarkSearch; +import com.alibaba.dashscope.utils.JsonUtils; +import com.google.gson.JsonObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.Message; +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.DefaultUsage; +import org.springframework.ai.chat.metadata.EmptyUsage; +import org.springframework.ai.chat.metadata.Usage; +import org.springframework.ai.chat.model.Generation; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.content.Media; +import org.springframework.ai.model.function.FunctionCallback; +import org.springframework.ai.qwen.QwenChatOptions; +import org.springframework.util.CollectionUtils; +import org.springframework.util.MimeType; +import org.springframework.util.StringUtils; +import reactor.core.publisher.Sinks; + +import java.util.ArrayList; +import java.util.Base64; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.BiFunction; +import java.util.function.BinaryOperator; +import java.util.function.Consumer; + +import static com.alibaba.dashscope.aigc.conversation.ConversationParam.ResultFormat.MESSAGE; +import static java.util.stream.Collectors.toList; + +public class QwenApiHelper { + + private static final Logger log = LoggerFactory.getLogger(QwenApiHelper.class); + + static boolean isMultimodalModelName(String modelName) { + // rough judgment + return modelName.contains("-vl-") || modelName.contains("-audio-"); + } + + public static boolean isSupportingIncrementalOutputModelName(String modelName) { + // rough judgment + return !(modelName.contains("-vl-") || modelName.contains("-audio-") || modelName.contains("-mt-")); + } + + static List toQwenMessages(List messages) { + return sanitizeMessages(messages).stream().map(QwenApiHelper::toQwenMessage).toList(); + } + + static com.alibaba.dashscope.common.Message toQwenMessage(Message message) { + return com.alibaba.dashscope.common.Message.builder() + .role(roleFrom(message)) + .content(contentFrom(message)) + .name(nameFrom(message)) + .toolCallId(toolCallIdFrom(message)) + .toolCalls(toolCallsFrom(message)) + .build(); + } + + private static String roleFrom(Message message) { + if (message.getMessageType() == MessageType.ASSISTANT) { + return Role.ASSISTANT.getValue(); + } + else if (message.getMessageType() == MessageType.SYSTEM) { + return Role.SYSTEM.getValue(); + } + else if (message.getMessageType() == MessageType.TOOL) { + return Role.TOOL.getValue(); + } + else { + return Role.USER.getValue(); + } + } + + private static String nameFrom(Message message) { + if (message.getMessageType() == MessageType.TOOL) { + return ((ToolResponseMessage) message).getResponses().get(0).name(); + } + return null; + } + + private static String contentFrom(Message message) { + if (message.getMessageType() == MessageType.TOOL) { + return ((ToolResponseMessage) message).getResponses().get(0).responseData(); + } + return message.getText(); + } + + private static String toolCallIdFrom(Message message) { + if (message.getMessageType() == MessageType.TOOL) { + return ((ToolResponseMessage) message).getResponses().get(0).id(); + } + return null; + } + + private static List toolCallsFrom(Message message) { + if (message.getMessageType() == MessageType.ASSISTANT && ((AssistantMessage) message).hasToolCalls()) { + return toToolCalls(((AssistantMessage) message).getToolCalls()); + } + return null; + } + + private static List toToolCalls(Collection toolExecutionRequests) { + return toolExecutionRequests.stream().map(QwenApiHelper::toToolCall).toList(); + } + + private static ToolCallBase toToolCall(AssistantMessage.ToolCall toolExecutionRequest) { + ToolCallFunction toolCallFunction = new ToolCallFunction(); + toolCallFunction.setId(toolExecutionRequest.id()); + ToolCallFunction.CallFunction callFunction = toolCallFunction.new CallFunction(); + callFunction.setName(toolExecutionRequest.name()); + callFunction.setArguments(toolExecutionRequest.arguments()); + toolCallFunction.setFunction(callFunction); + return toolCallFunction; + } + + private static List toToolFunctions(Collection toolSpecifications) { + if (CollectionUtils.isEmpty(toolSpecifications)) { + return Collections.emptyList(); + } + + return toolSpecifications.stream().map(QwenApiHelper::toToolFunction).toList(); + } + + private static ToolBase toToolFunction(FunctionCallback toolCallback) { + FunctionDefinition functionDefinition = FunctionDefinition.builder() + .name(toolCallback.getName()) + .description(getOrDefault(toolCallback.getDescription(), "")) + .parameters(toParameters(toolCallback)) + .build(); + return ToolFunction.builder().function(functionDefinition).build(); + } + + private static JsonObject toParameters(FunctionCallback toolCallback) { + if (toolCallback.getInputTypeSchema() != null) { + return JsonUtils.parse(toolCallback.getInputTypeSchema()); + } + else { + return JsonUtils.toJsonObject(Collections.emptyMap()); + } + } + + static List toQwenMultiModalMessages(List messages) { + return messages.stream().map(QwenApiHelper::toQwenMultiModalMessage).collect(toList()); + } + + private static MultiModalMessage toQwenMultiModalMessage(Message message) { + return MultiModalMessage.builder().role(roleFrom(message)).content(toMultiModalContents(message)).build(); + } + + private static List> toMultiModalContents(Message message) { + List> contents = new LinkedList<>(); + if (StringUtils.hasText(message.getText())) { + contents.add(toMultiModalContent(message.getText())); + } + + List media = switch (message.getMessageType()) { + case USER -> ((UserMessage) message).getMedia(); + case ASSISTANT -> ((AssistantMessage) message).getMedia(); + default -> Collections.emptyList(); + }; + + media.stream().map(QwenApiHelper::toMultiModalContent).forEach(contents::add); + + if (message.getMessageType() == MessageType.TOOL) { + ToolResponseMessage toolMessage = (ToolResponseMessage) message; + List toolResponses = toolMessage.getResponses(); + if (!CollectionUtils.isEmpty(toolResponses)) { + for (ToolResponseMessage.ToolResponse toolResponse : toolResponses) { + contents.add(Map.of("content", toolResponse.responseData(), "tool_call_id", toolResponse.id())); + } + } + } + + return contents; + } + + static Map toMultiModalContent(Media media) { + MimeType mimeType = media.getMimeType(); + return switch (mimeType.getType()) { + case "image" -> Collections.singletonMap("image", fromMediaData(mimeType, media.getData())); + case "audio" -> Collections.singletonMap("audio", fromMediaData(mimeType, media.getData())); + case "video" -> Collections.singletonMap("video", fromMediaData(mimeType, media.getData())); + case "text" -> Collections.singletonMap("text", media.getData()); + default -> Collections.emptyMap(); + }; + } + + static Map toMultiModalContent(String text) { + return Collections.singletonMap("text", text); + } + + private static String fromMediaData(MimeType mimeType, Object mediaContentData) { + if (mediaContentData instanceof byte[] bytes) { + // Assume the bytes are an image. So, convert the bytes to a base64 encoded + // following the prefix pattern. + 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. + return text; + } + else { + throw new IllegalArgumentException( + "Unsupported media data type: " + mediaContentData.getClass().getSimpleName()); + } + } + + static List sanitizeMessages(List messages) { + LinkedList sanitizedMessages = messages.stream() + .reduce(new LinkedList<>(), messageAccumulator(), messageCombiner()); + + // Ensure the last message is a user/tool_execution_result message + while (!sanitizedMessages.isEmpty() && !isInputMessageType(sanitizedMessages.getLast())) { + Message removedMessage = sanitizedMessages.removeLast(); + log.warn("The last message should be a user/tool_execution_result message, but found: {}", removedMessage); + } + + return sanitizedMessages; + } + + private static BiFunction, Message, LinkedList> messageAccumulator() { + return (acc, message) -> { + MessageType type = message.getMessageType(); + if (acc.isEmpty()) { + // Ensure the first message is a system message or a user message. + if (type == MessageType.SYSTEM || type == MessageType.USER) { + acc.add(message); + } + else { + log.warn("The first message should be a system message or a user message, but found: {}", message); + } + return acc; + } + + if (type == MessageType.SYSTEM) { + if (acc.getFirst().getMessageType() == MessageType.SYSTEM) { + log.warn("Drop existed system message: {}", acc); + acc.removeFirst(); + } + acc.addFirst(message); + return acc; + } + + MessageType lastType = acc.getLast().getMessageType(); + if (lastType == MessageType.SYSTEM && type != MessageType.USER) { + log.warn("The first non-system message must be a user message, but found: {}", message); + return acc; + } + + if (type == MessageType.USER) { + while (acc.getLast().getMessageType() != MessageType.SYSTEM && !isNormalAiType(acc.getLast())) { + Message removedMessage = acc.removeLast(); + log.warn( + "Tool execution result should follow a tool execution request message. Drop duplicated message: {}", + removedMessage); + } + } + else if (type == MessageType.TOOL) { + while (!isToolCallAiType(acc.getLast())) { + Message removedMessage = acc.removeLast(); + log.warn( + "Tool execution result should follow a tool execution request message. Drop duplicated message: {}", + removedMessage); + } + } + else if (type == MessageType.ASSISTANT) { + while (!isInputMessageType(acc.getLast())) { + Message removedMessage = acc.removeLast(); + log.warn( + "AI message should follow a user/tool_execution_result message. Drop duplicated message: {}", + removedMessage); + } + } + + acc.add(message); + return acc; + }; + } + + private static BinaryOperator> messageCombiner() { + return (acc1, acc2) -> { + throw new UnsupportedOperationException("Parallel stream not supported"); + }; + } + + private static boolean isInputMessageType(Message message) { + MessageType type = message.getMessageType(); + return type == MessageType.USER || type == MessageType.TOOL; + } + + private static boolean isNormalAiType(Message message) { + return message.getMessageType() == MessageType.ASSISTANT && !((AssistantMessage) message).hasToolCalls(); + } + + private static boolean isToolCallAiType(Message message) { + return message.getMessageType() == MessageType.ASSISTANT && ((AssistantMessage) message).hasToolCalls(); + } + + static GenerationParam toGenerationParam(String apiKey, Prompt prompt, boolean incrementalOutput, + Consumer> generationParamCustomizer) { + QwenChatOptions options = (QwenChatOptions) prompt.getOptions(); + validateGenerationParameters(options); + + GenerationParam.GenerationParamBuilder builder = GenerationParam.builder() + .apiKey(apiKey) + .model(options.getModel()) + .topP(options.getTopP()) + .topK(options.getTopK()) + .enableSearch(getOrDefault(options.isEnableSearch(), false)) + .searchOptions(toQwenSearchOptions(options.getSearchOptions())) + .seed(options.getSeed()) + .repetitionPenalty(frequencyPenaltyToRepetitionPenalty(options.getFrequencyPenalty())) + .maxTokens(options.getMaxTokens()) + .messages(toQwenMessages(prompt.getInstructions())) + .responseFormat(options.getResponseFormat()) + .resultFormat(MESSAGE) + .incrementalOutput(incrementalOutput); + + if (options.getTemperature() != null) { + builder.temperature(options.getTemperature().floatValue()); + } + + if (options.getStopSequences() != null) { + builder.stopStrings(options.getStopSequences()); + } + + if (!CollectionUtils.isEmpty(options.getToolCallbacks())) { + builder.tools(toToolFunctions(options.getToolCallbacks())); + if (options.getToolChoice() != null) { + Object toolChoiceObject = options.getToolChoice(); + if (toolChoiceObject instanceof FunctionCallback toolCallback) { + builder.toolChoice(toToolFunction(toolCallback)); + } + else { + builder.toolChoice(toolChoiceObject); + } + } + } + + if (options.getTranslationOptions() != null) { + // no java field is provided yet + builder.parameter("translation_options", toQwenTranslationOptions(options.getTranslationOptions())); + } + + if (options.getCustom() != null) { + // no java field is provided yet + builder.parameter("custom", options.getCustom()); + } + + if (generationParamCustomizer != null) { + generationParamCustomizer.accept(builder); + } + + return builder.build(); + } + + static void validateGenerationParameters(QwenChatOptions options) { + if (options.getVlHighResolutionImages() != null) { + throw new UnsupportedOperationException( + "'vlHighResolutionImages' parameter is not supported by " + options.getModel()); + } + } + + static MultiModalConversationParam toMultiModalConversationParam(String apiKey, Prompt prompt, + boolean incrementalOutput, + Consumer> multimodalConversationParamCustomizer) { + QwenChatOptions options = (QwenChatOptions) prompt.getOptions(); + validateMultimodalConversationParameters(options); + + MultiModalConversationParam.MultiModalConversationParamBuilder builder = MultiModalConversationParam + .builder() + .apiKey(apiKey) + .model(options.getModel()) + .topP(options.getTopP()) + .topK(options.getTopK()) + .enableSearch(getOrDefault(options.isEnableSearch(), false)) + .seed(options.getSeed()) + .maxTokens(options.getMaxTokens()) + .messages(toQwenMultiModalMessages(prompt.getInstructions())) + .incrementalOutput(incrementalOutput); + + if (options.getTemperature() != null) { + builder.temperature(options.getTemperature().floatValue()); + } + + if (options.getVlHighResolutionImages() != null) { + // no java field is provided yet + builder.parameter("vl_high_resolution_images", options.getVlHighResolutionImages()); + } + + if (options.getCustom() != null) { + // no java field is provided yet + builder.parameter("custom", options.getCustom()); + } + + if (multimodalConversationParamCustomizer != null) { + multimodalConversationParamCustomizer.accept(builder); + } + + return builder.build(); + } + + static void validateMultimodalConversationParameters(QwenChatOptions options) { + if (options.getSearchOptions() != null) { + throw new UnsupportedOperationException( + "'searchOptions' parameter is not supported by " + options.getModel()); + } + + if (options.getFrequencyPenalty() != null) { + throw new UnsupportedOperationException( + "'frequencyPenalty' parameter is not supported by " + options.getModel()); + } + + if (!CollectionUtils.isEmpty(options.getStopSequences())) { + throw new UnsupportedOperationException( + "'stopSequences' parameter is not supported by " + options.getModel()); + } + + if (!CollectionUtils.isEmpty(options.getToolCallbacks()) || !CollectionUtils.isEmpty(options.getToolNames()) + || !CollectionUtils.isEmpty(options.getToolContext()) || options.getToolChoice() != null) { + throw new UnsupportedOperationException("'tools' parameter is not supported by " + options.getModel()); + } + + if (options.getTranslationOptions() != null) { + throw new UnsupportedOperationException( + "'translationOptions' parameter is not supported by " + options.getModel()); + } + + if (options.getResponseFormat() != null) { + throw new UnsupportedOperationException( + "'responseFormat' parameter is not supported by " + options.getModel()); + } + } + + static com.alibaba.dashscope.aigc.generation.SearchOptions toQwenSearchOptions( + QwenChatOptions.SearchOptions searchOptions) { + if (searchOptions == null) { + return null; + } + + return com.alibaba.dashscope.aigc.generation.SearchOptions.builder() + .citationFormat(searchOptions.citationFormat()) + .enableCitation(searchOptions.enableCitation()) + .enableSource(searchOptions.enableSource()) + .forcedSearch(searchOptions.forcedSearch()) + .searchStrategy(searchOptions.searchStrategy()) + .build(); + } + + static Map toQwenTranslationOptions(QwenChatOptions.TranslationOptions translationOptions) { + if (translationOptions == null) { + return null; + } + + // no java class is provided yet + Map translationOptionsMap = new HashMap<>(5); + translationOptionsMap.put("source_lang", translationOptions.sourceLang()); + translationOptionsMap.put("target_lang", translationOptions.targetLang()); + translationOptionsMap.put("terms", toTermList(translationOptions.terms())); + translationOptionsMap.put("tm_list", toTermList(translationOptions.tmList())); + translationOptionsMap.put("domains", translationOptions.domains()); + return translationOptionsMap; + } + + static List> toTermList(List list) { + if (list == null) { + return null; + } + + return list.stream().map(term -> Map.of("source", term.source(), "target", term.target())).toList(); + } + + static boolean isStreamingToolCall(GenerationResult result) { + return Optional.of(result) + .map(GenerationResult::getOutput) + .map(GenerationOutput::getChoices) + .filter(choices -> !choices.isEmpty()) + .map(choices -> choices.get(0)) + .map(GenerationOutput.Choice::getMessage) + .map(com.alibaba.dashscope.common.Message::getToolCalls) + .map(toolCalls -> !toolCalls.isEmpty()) + .orElse(false); + } + + static boolean isStreamingDone(GenerationResult result) { + return getFinishReason(result) != null; + } + + private static String getFinishReason(GenerationResult result) { + return Optional.of(result) + .map(GenerationResult::getOutput) + .map(GenerationOutput::getChoices) + .filter(choices -> !choices.isEmpty()) + .map(choices -> choices.get(0)) + .map(QwenApiHelper::getFinishReason) + .orElse(null); + } + + private static String getFinishReason(GenerationOutput.Choice choice) { + String finishReason = choice.getFinishReason(); + return StringUtils.hasText(finishReason) && !"null".equals(finishReason) ? finishReason : null; + } + + private static String getFinishReason(MultiModalConversationOutput.Choice choice) { + String finishReason = choice.getFinishReason(); + return StringUtils.hasText(finishReason) && !"null".equals(finishReason) ? finishReason : null; + } + + static GenerationResult newGenerationResult() { + DashScopeResult emptyResult = new DashScopeResult(); + emptyResult.setOutput(new JsonObject()); + return GenerationResult.fromDashScopeResult(emptyResult); + } + + static GenerationResult mergeResult(GenerationResult previous, GenerationResult current) { + String requestId = getOrDefault(current.getRequestId(), previous.getRequestId()); + GenerationUsage usage = getOrDefault(current.getUsage(), previous.getUsage()); + GenerationOutput output = mergeOutput(previous.getOutput(), current.getOutput()); + + GenerationResult result = newGenerationResult(); + result.setRequestId(requestId); + result.setUsage(usage); + result.setOutput(output); + + return result; + } + + private static GenerationOutput mergeOutput(GenerationOutput previous, GenerationOutput current) { + GenerationOutput output = new GenerationOutput(); + + String finishReason = getOrDefault(current.getFinishReason(), previous.getFinishReason()); + String text = merge(current.getText(), previous.getText()); + List choices = mergeChoices(output, previous.getChoices(), current.getChoices()); + SearchInfo searchInfo = mergeSearchInfo(previous.getSearchInfo(), current.getSearchInfo()); + + output.setFinishReason(finishReason); + output.setText(text); + output.setChoices(choices); + output.setSearchInfo(searchInfo); + + return output; + } + + private static SearchInfo mergeSearchInfo(SearchInfo previous, SearchInfo current) { + if (previous == null) { + return current; + } + if (current == null) { + return previous; + } + List searchResults = merge(previous.getSearchResults(), current.getSearchResults()); + return SearchInfo.builder().searchResults(searchResults).build(); + } + + private static List mergeChoices(GenerationOutput output, + List previous, List current) { + List choices = new ArrayList<>(1); // in most cases, + // there is only one. + GenerationOutput.Choice lastPreviousChoice = null; + + if (previous != null) { + lastPreviousChoice = previous.get(previous.size() - 1); + if (previous.size() > 1) { + choices.addAll(previous.subList(0, previous.size() - 1)); + } + } + + if (current != null) { + if (current.size() > 1) { + throw new IllegalStateException("Currently only one choice is supported per message!"); + } + var currentChoice = current.iterator().next(); + choices.add(mergeChoice(output, lastPreviousChoice, currentChoice)); + } + else { + if (lastPreviousChoice != null) { + choices.add(lastPreviousChoice); + } + } + + return choices; + } + + private static GenerationOutput.Choice mergeChoice(GenerationOutput output, GenerationOutput.Choice previous, + GenerationOutput.Choice current) { + if (previous == null) { + return current; + } + if (current == null) { + return previous; + } + + Integer index = getOrDefault(current.getIndex(), previous.getIndex()); + String finishReason = getOrDefault(current.getFinishReason(), previous.getFinishReason()); + com.alibaba.dashscope.common.Message message = mergeMessage(previous.getMessage(), current.getMessage()); + + GenerationOutput.Choice choice = output.new Choice(); + choice.setIndex(index); + choice.setFinishReason(finishReason); + choice.setMessage(message); + + return choice; + } + + private static com.alibaba.dashscope.common.Message mergeMessage(com.alibaba.dashscope.common.Message previous, + com.alibaba.dashscope.common.Message current) { + + if (previous == null) { + return current; + } + if (current == null) { + return previous; + } + + String content = merge(previous.getContent(), current.getContent()); + String reasoningContent = merge(previous.getReasoningContent(), current.getReasoningContent()); + String role = getOrDefault(current.getRole(), previous.getRole()); + role = getOrDefault(role, Role.ASSISTANT.getValue()); + String name = getOrDefault(current.getName(), previous.getName()); + List contents = merge(previous.getContents(), current.getContents()); + List toolCalls = mergeToolCalls(previous.getToolCalls(), current.getToolCalls()); + String toolCallId = getOrDefault(current.getToolCallId(), previous.getToolCallId()); + + return com.alibaba.dashscope.common.Message.builder() + .content(content) + .contents(contents) + .toolCalls(toolCalls) + .toolCallId(toolCallId) + .name(name) + .role(role) + .reasoningContent(reasoningContent) + .build(); + } + + private static List mergeToolCalls(List previous, List current) { + List toolCalls = new ArrayList<>(1); // in most cases, there is only + // one. + ToolCallBase lastPreviousTooCall = null; + + if (previous != null) { + lastPreviousTooCall = previous.get(previous.size() - 1); + if (previous.size() > 1) { + toolCalls.addAll(previous.subList(0, previous.size() - 1)); + } + } + + if (current != null) { + if (current.size() > 1) { + throw new IllegalStateException("Currently only one tool call is supported per message!"); + } + var currentToolCall = current.iterator().next(); + if (StringUtils.hasText(currentToolCall.getId())) { + if (lastPreviousTooCall != null) { + toolCalls.add(lastPreviousTooCall); + } + toolCalls.add(currentToolCall); + } + else { + toolCalls.add(mergeToolCall(lastPreviousTooCall, currentToolCall)); + } + } + else { + if (lastPreviousTooCall != null) { + toolCalls.add(lastPreviousTooCall); + } + } + + return toolCalls; + } + + private static ToolCallBase mergeToolCall(ToolCallBase previous, ToolCallBase current) { + if (previous == null) { + return current; + } + + String id = (StringUtils.hasText(current.getId()) ? current.getId() : previous.getId()); + String type = getOrDefault(current.getType(), previous.getType()); + + if (previous instanceof ToolCallFunction previousToolCallFunction + && current instanceof ToolCallFunction currentToolCallFunction) { + ToolCallFunction newToolCall = new ToolCallFunction(); + ToolCallFunction.CallFunction callFunction = mergeToolCallFunction(newToolCall, + previousToolCallFunction.getFunction(), currentToolCallFunction.getFunction()); + newToolCall.setFunction(callFunction); + newToolCall.setId(id); + newToolCall.setType(type); + return newToolCall; + } + else if (current instanceof ToolCallCodeInterpreter) { + ToolCallCodeInterpreter newToolCall = new ToolCallCodeInterpreter(); + newToolCall.setId(id); + newToolCall.setType(type); + return newToolCall; + } + else if (previous instanceof ToolCallQuarkSearch previousQuarkToolCall + && current instanceof ToolCallQuarkSearch currentQuarkToolCall) { + Map quarkSearch = merge(previousQuarkToolCall.getQuarkSearch(), + currentQuarkToolCall.getQuarkSearch()); + ToolCallQuarkSearch newToolCall = new ToolCallQuarkSearch(); + newToolCall.setId(id); + newToolCall.setType(type); + newToolCall.setQuarkSearch(quarkSearch); + return newToolCall; + } + else { + return current; + } + } + + private static ToolCallFunction.CallFunction mergeToolCallFunction(ToolCallFunction toolCallFunction, + ToolCallFunction.CallFunction previous, ToolCallFunction.CallFunction current) { + if (previous == null) { + return current; + } + + String name = merge(previous.getName(), current.getName()); + String arguments = merge(previous.getArguments(), current.getArguments()); + String output = merge(previous.getOutput(), current.getOutput()); + + ToolCallFunction.CallFunction callFunction = toolCallFunction.new CallFunction(); + callFunction.setName(name); + callFunction.setArguments(arguments); + callFunction.setOutput(output); + return callFunction; + } + + private static Map merge(Map previous, Map current) { + if (previous == null) { + return current; + } + if (current == null) { + return previous; + } + Map merged = new HashMap<>(previous); + merged.putAll(current); + return merged; + } + + private static List merge(List previous, List current) { + if (previous == null) { + return current; + } + if (current == null) { + return previous; + } + List merged = new ArrayList<>(previous.size() + current.size()); + merged.addAll(previous); + merged.addAll(current); + return merged; + } + + private static String merge(String previous, String current) { + if (previous == null) { + return current; + } + if (current == null) { + return previous; + } + return previous + current; + } + + static Float frequencyPenaltyToRepetitionPenalty(Double frequencyPenalty) { + // repetitionPenalty: + // https://www.alibabacloud.com/help/en/model-studio/use-qwen-by-calling-api#2ed5ee7377fum + // frequencyPenalty: + // https://platform.openai.com/docs/api-reference/chat/create#chat-create-frequency_penalty + // map: [-2, 2] -> (0, ∞), and 0 -> 1 + // use logit function (https://en.wikipedia.org/wiki/Logit) + + if (frequencyPenalty == null) { + return null; + } + else if (frequencyPenalty >= 2) { + return Float.POSITIVE_INFINITY; + } + else if (frequencyPenalty < -2) { + throw new IllegalArgumentException("Value of frequencyPenalty must be within [-2.0, 2.0]"); + } + + // limit the input to 0.5 to 1 (as the repetition penalty is a positive value) + double x = (frequencyPenalty + 6) / 8; + // make sure repetition penalty is 1 when frequency penalty is 0 + double denominator = logit(0.75d); + + return (float) (logit(x) / denominator); + } + + static Double repetitionPenaltyToFrequencyPenalty(Float repetitionPenalty) { + // repetitionPenalty: + // https://www.alibabacloud.com/help/en/model-studio/use-qwen-by-calling-api#2ed5ee7377fum + // frequencyPenalty: + // https://platform.openai.com/docs/api-reference/chat/create#chat-create-frequency_penalty + // map: (0, ∞) -> [-2, 2], and 1 -> 0 + // use sigmoid function (https://en.wikipedia.org/wiki/Sigmoid_function) + + if (repetitionPenalty == null) { + return null; + } + else if (repetitionPenalty <= 0) { + throw new IllegalArgumentException("Value of repetitionPenalty must be positive number"); + } + + // make sure frequency penalty is 0 when repetition penalty is 1 + // see frequencyPenaltyToRepetitionPenalty() + double factor = logit(0.75d); + double y = sigmoid(repetitionPenalty.doubleValue() * factor); + + // make sure frequency penalty is between -2 and 2 + return y * 8 - 6; + } + + private static double logit(double x) { + return Math.log(x / (1 - x)); + } + + private static double sigmoid(double x) { + return 1.0 / (1.0 + Math.exp(-x)); + } + + static List generationsFrom(GenerationResult result) { + return Optional.of(result) + .map(GenerationResult::getOutput) + .map(GenerationOutput::getChoices) + .orElse(Collections.emptyList()) + .stream() + .map(choice -> buildGeneration(result.getRequestId(), choice)) + .toList(); + } + + private static Generation buildGeneration(String id, GenerationOutput.Choice choice) { + com.alibaba.dashscope.common.Message message = choice.getMessage(); + List toolCalls = Optional.ofNullable(message.getToolCalls()) + .orElse(Collections.emptyList()) + .stream() + .filter(ToolCallFunction.class::isInstance) + .map(ToolCallFunction.class::cast) + .map(toolCall -> new AssistantMessage.ToolCall(toolCall.getId(), toolCall.getType(), + toolCall.getFunction().getName(), toolCall.getFunction().getArguments())) + .toList(); + + String finishReason = getFinishReason(choice); + List media = new LinkedList<>(); + String text = message.getContent(); + List contents = message.getContents(); + if (!CollectionUtils.isEmpty(contents)) { + for (MessageContentBase content : contents) { + if (content instanceof MessageContentImageURL imageContent) { + media + .add(Media.builder().mimeType(Media.Format.IMAGE_PNG).data(imageContent.getImageURL()).build()); + } + else if (content instanceof MessageContentText textContent) { + media.add(Media.builder().mimeType(Media.Format.DOC_TXT).data(textContent.getText()).build()); + } + } + } + Map metadata = CollectionUtils.newHashMap(6); + putIfNotNull(metadata, "id", id); + putIfNotNull(metadata, "role", message.getRole()); + putIfNotNull(metadata, "name", message.getName()); + putIfNotNull(metadata, "index", choice.getIndex()); + putIfNotNull(metadata, "finishReason", finishReason); + putIfNotNull(metadata, "reasoningContent", message.getReasoningContent()); + + return new Generation(new AssistantMessage(text, metadata, toolCalls, media), + ChatGenerationMetadata.builder().finishReason(finishReason).build()); + } + + static Usage defaultUsageFrom(GenerationUsage qwenUsage) { + return qwenUsage == null ? new EmptyUsage() : new DefaultUsage(qwenUsage.getInputTokens(), + qwenUsage.getOutputTokens(), qwenUsage.getTotalTokens(), qwenUsage); + } + + static List generationsFrom(MultiModalConversationResult result) { + return Optional.of(result) + .map(MultiModalConversationResult::getOutput) + .map(MultiModalConversationOutput::getChoices) + .orElse(Collections.emptyList()) + .stream() + .map(choice -> buildGeneration(result.getRequestId(), choice)) + .toList(); + } + + private static Generation buildGeneration(String id, MultiModalConversationOutput.Choice choice) { + com.alibaba.dashscope.common.MultiModalMessage message = choice.getMessage(); + List toolCalls = Collections.emptyList(); + + String finishReason = getFinishReason(choice); + List media = new LinkedList<>(); + List textContents = new LinkedList<>(); + List> contents = message.getContent(); + if (!CollectionUtils.isEmpty(contents)) { + for (Map content : contents) { + if (content.containsKey("text")) { + textContents.add((String) content.get("text")); + } + + if (content.containsKey("image")) { + media.add(Media.builder().mimeType(Media.Format.IMAGE_PNG).data(content.get("image")).build()); + } + } + } + + String text = String.join("\n", textContents); + + Map metadata = CollectionUtils.newHashMap(3); + putIfNotNull(metadata, "id", id); + putIfNotNull(metadata, "role", message.getRole()); + putIfNotNull(metadata, "finishReason", finishReason); + + return new Generation(new AssistantMessage(text, metadata, toolCalls, media), + ChatGenerationMetadata.builder().finishReason(finishReason).build()); + } + + static Usage defaultUsageFrom(MultiModalConversationUsage qwenUsage) { + return qwenUsage == null ? new EmptyUsage() : new DefaultUsage(qwenUsage.getInputTokens(), + qwenUsage.getOutputTokens(), qwenUsage.getTotalTokens(), qwenUsage); + } + + static QwenSearchInfo toQwenSearchInfo(SearchInfo searchInfo) { + List searchResults = searchInfo == null + || CollectionUtils.isEmpty(searchInfo.getSearchResults()) ? Collections.emptyList() + : searchInfo.getSearchResults().stream().map(QwenApiHelper::toQwenSearchResult).toList(); + + return QwenSearchInfo.builder().searchResults(searchResults).build(); + } + + private static QwenSearchResult toQwenSearchResult(SearchInfo.SearchResult searchResult) { + return QwenSearchResult.builder() + .siteName(searchResult.getSiteName()) + .icon(searchResult.getIcon()) + .index(searchResult.getIndex()) + .title(searchResult.getTitle()) + .url(searchResult.getUrl()) + .build(); + } + + static ResultCallback toQwenResultCallback(Sinks.Many sink) { + return new ResultCallback<>() { + @Override + public void onEvent(T result) { + sink.tryEmitNext(result); + } + + @Override + public void onComplete() { + sink.tryEmitComplete(); + } + + @Override + public void onError(Exception e) { + sink.tryEmitError(e); + } + }; + } + + public static T getOrDefault(T value, T defaultValue) { + return value != null ? value : defaultValue; + } + + public static List copyIfNotNull(List list) { + return list == null ? null : Collections.unmodifiableList(list); + } + + public static Set copyIfNotNull(Set set) { + return set == null ? null : Collections.unmodifiableSet(set); + } + + public static Map copyIfNotNull(Map map) { + return map == null ? null : Collections.unmodifiableMap(map); + } + + public static void putIfNotNull(Map map, K key, V value) { + if (value != null) { + map.put(key, value); + } + } + +} diff --git a/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/api/QwenModel.java b/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/api/QwenModel.java new file mode 100644 index 00000000000..ee635730ee6 --- /dev/null +++ b/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/api/QwenModel.java @@ -0,0 +1,71 @@ +package org.springframework.ai.qwen.api; + +import org.springframework.ai.model.ChatModelDescription; + +public enum QwenModel implements ChatModelDescription { + + QWEN_TURBO("qwen-turbo", "Qwen base model, stable version."), + QWEN_TURBO_LATEST("qwen-turbo-latest", "Qwen base model, latest version."), + QWEN_PLUS("qwen-plus", "Qwen plus model, stable version."), + QWEN_PLUS_LATEST("qwen-plus-latest", "Qwen plus model, latest version."), + QWEN_MAX("qwen-max", "Qwen max model, stable version."), + QWEN_MAX_LATEST("qwen-max-latest", "Qwen max model, latest version."), + QWEN_LONG("qwen-long", "Qwen long model, 10m context."), + QWEN_7B_CHAT("qwen-7b-chat", "Qwen open sourced 7-billion-parameters model."), + QWEN_14B_CHAT("qwen-14b-chat", "Qwen open sourced 14-billion-parameters model."), + QWEN_72B_CHAT("qwen-72b-chat", "Qwen open sourced 72-billion-parameters model."), + QWEN1_5_7B_CHAT("qwen1.5-7b-chat", "Qwen open sourced 7-billion-parameters model (v1.5)."), + QWEN1_5_14B_CHAT("qwen1.5-14b-chat", "Qwen open sourced 14-billion-parameters model (v1.5)."), + QWEN1_5_32B_CHAT("qwen1.5-32b-chat", "Qwen open sourced 32-billion-parameters model (v1.5)."), + QWEN1_5_72B_CHAT("qwen1.5-72b-chat", "Qwen open sourced 72-billion-parameters model (v1.5)."), + QWEN2_0_5B_INSTRUCT("qwen2-0.5b-instruct", "Qwen open sourced 0.5-billion-parameters model (v2)."), + QWEN2_1_5B_INSTRUCT("qwen2-1.5b-instruct", "Qwen open sourced 1.5-billion-parameters model (v2)."), + QWEN2_7B_INSTRUCT("qwen2-7b-instruct", "Qwen open sourced 7-billion-parameters model (v2)."), + QWEN2_72B_INSTRUCT("qwen2-72b-instruct", "Qwen open sourced 72-billion-parameters model (v2)."), + QWEN2_57B_A14B_INSTRUCT("qwen2-57b-a14b-instruct", + "Qwen open sourced 57-billion-parameters and 14-billion-activation-parameters MOE model (v2)."), + QWEN2_5_0_5B_INSTRUCT("qwen2.5-0.5b-instruct", "Qwen open sourced 0.5-billion-parameters model (v2.5)."), + QWEN2_5_1_5B_INSTRUCT("qwen2.5-1.5b-instruct", "Qwen open sourced 1.5-billion-parameters model (v2.5)."), + QWEN2_5_3B_INSTRUCT("qwen2.5-3b-instruct", "Qwen open sourced 3-billion-parameters model (v2.5)."), + QWEN2_5_7B_INSTRUCT("qwen2.5-7b-instruct", "Qwen open sourced 7-billion-parameters model (v2.5)."), + QWEN2_5_14B_INSTRUCT("qwen2.5-14b-instruct", "Qwen open sourced 14-billion-parameters model (v2.5)."), + QWEN2_5_32B_INSTRUCT("qwen2.5-32b-instruct", "Qwen open sourced 32-billion-parameters model (v2.5)."), + QWEN2_5_72B_INSTRUCT("qwen2.5-72b-instruct", "Qwen open sourced 72-billion-parameters model (v2.5)."), + QWEN_VL_PLUS("qwen-vl-plus", "Qwen multi-modal model, supports image and text information, stable version."), + QWEN_VL_PLUS_LATEST("qwen-vl-plus-latest", + "Qwen multi-modal model, supports image and text information, latest version."), + QWEN_VL_MAX("qwen-vl-max", + "Qwen multi-modal model, supports image and text information, offers optimal performance, stable version."), + QWEN_VL_MAX_LATEST("qwen-vl-max-latest", + "Qwen multi-modal model, supports image and text information, offers optimal performance, latest version."), + QWEN_AUDIO_TURBO("qwen-audio-turbo", "Qwen audio understanding model, stable version."), + QWEN_AUDIO_TURBO_LATEST("qwen-audio-turbo-latest", "Qwen audio understanding model, latest version."), + QWEN_MT_TURBO("qwen-mt-turbo", "Qwen turbo model for translation."), + QWEN_MT_PLUS("qwen-mt-plus", "Qwen plus model for translation."), + QWQ_PLUS("qwq-plus", "Qwen reasoning model, stable version."), + QWQ_PLUS_LATEST("qwq-plus-latest", "Qwen reasoning model, latest version."); + + private final String name; + + private final String description; + + QwenModel(String name) { + this(name, ""); + } + + QwenModel(String name, String description) { + this.name = name; + this.description = description; + } + + @Override + public String getName() { + return this.name; + } + + @Override + public String getDescription() { + return this.description; + } + +} diff --git a/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/api/QwenSearchInfo.java b/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/api/QwenSearchInfo.java new file mode 100644 index 00000000000..dd5ab0cefac --- /dev/null +++ b/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/api/QwenSearchInfo.java @@ -0,0 +1,31 @@ +package org.springframework.ai.qwen.api; + +import java.util.List; + +/** + * The information searched on the Internet will be returned after the search_options + * parameter is set. + * + * @param searchResults a list of results from online searches + */ +public record QwenSearchInfo(List searchResults) { + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private List searchResults; + + public Builder searchResults(List searchResults) { + this.searchResults = searchResults; + return this; + } + + public QwenSearchInfo build() { + return new QwenSearchInfo(searchResults); + } + + } +} diff --git a/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/api/QwenSearchResult.java b/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/api/QwenSearchResult.java new file mode 100644 index 00000000000..20b9ddf1467 --- /dev/null +++ b/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/api/QwenSearchResult.java @@ -0,0 +1,63 @@ +package org.springframework.ai.qwen.api; + +/** + * Results from online searches. + * + * @see QwenSearchInfo + * @param siteName the name of the website from which the search results came + * @param icon the URL of the icon from the source website, or an empty string if there is + * no icon + * @param index the sequence number of the search result, indicating the index of the + * search result in search_results + * @param title the title of the search result + * @param url the URL of the search result + */ +public record QwenSearchResult(String siteName, String icon, Integer index, String title, String url) { + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private String siteName; + + private String icon; + + private Integer index; + + private String title; + + private String url; + + public Builder siteName(String siteName) { + this.siteName = siteName; + return this; + } + + public Builder icon(String icon) { + this.icon = icon; + return this; + } + + public Builder index(Integer index) { + this.index = index; + return this; + } + + public Builder title(String title) { + this.title = title; + return this; + } + + public Builder url(String url) { + this.url = url; + return this; + } + + public QwenSearchResult build() { + return new QwenSearchResult(siteName, icon, index, title, url); + } + + } +} diff --git a/models/spring-ai-qwen/src/main/resources/META-INF/spring/aot.factories b/models/spring-ai-qwen/src/main/resources/META-INF/spring/aot.factories new file mode 100644 index 00000000000..b49c0accf16 --- /dev/null +++ b/models/spring-ai-qwen/src/main/resources/META-INF/spring/aot.factories @@ -0,0 +1,2 @@ +org.springframework.aot.hint.RuntimeHintsRegistrar=\ + org.springframework.ai.qwen.aot.QwenRuntimeHints \ No newline at end of file diff --git a/models/spring-ai-qwen/src/test/java/org/springframework/ai/qwen/MockWeatherService.java b/models/spring-ai-qwen/src/test/java/org/springframework/ai/qwen/MockWeatherService.java new file mode 100644 index 00000000000..fc682d1192c --- /dev/null +++ b/models/spring-ai-qwen/src/test/java/org/springframework/ai/qwen/MockWeatherService.java @@ -0,0 +1,76 @@ +package org.springframework.ai.qwen; + +import com.fasterxml.jackson.annotation.JsonClassDescription; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyDescription; + +import java.util.function.Function; + +public class MockWeatherService implements Function { + + @Override + public Response apply(Request request) { + + double temperature = 0; + if (request.location().contains("Paris")) { + temperature = 15; + } + else if (request.location().contains("Tokyo")) { + temperature = 10; + } + else if (request.location().contains("San Francisco")) { + temperature = 30; + } + + return new Response(temperature, 15, temperature / 2, temperature * 2, 53, 45, request.unit); + } + + /** + * Temperature units. + */ + public enum Unit { + + /** + * Celsius. + */ + C("metric"), + /** + * Fahrenheit. + */ + F("imperial"); + + /** + * Human readable unit name. + */ + public final String unitName; + + Unit(String text) { + this.unitName = text; + } + + } + + /** + * Weather Function request. + */ + @JsonInclude(Include.NON_NULL) + @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("lat") @JsonPropertyDescription("The city latitude") double lat, + @JsonProperty("lon") @JsonPropertyDescription("The city longitude") double lon, + @JsonProperty(required = true, value = "unit") @JsonPropertyDescription("Temperature unit") Unit unit) { + + } + + /** + * Weather Function response. + */ + public record Response(double temperature, double feels_like, double temp_min, double temp_max, int pressure, + int humidity, Unit unit) { + + } + +} diff --git a/models/spring-ai-qwen/src/test/java/org/springframework/ai/qwen/QwenChatModelIT.java b/models/spring-ai-qwen/src/test/java/org/springframework/ai/qwen/QwenChatModelIT.java new file mode 100644 index 00000000000..52f7100deaa --- /dev/null +++ b/models/spring-ai-qwen/src/test/java/org/springframework/ai/qwen/QwenChatModelIT.java @@ -0,0 +1,305 @@ +package org.springframework.ai.qwen; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.metadata.ChatResponseMetadata; +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.chat.prompt.PromptTemplate; +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.qwen.api.QwenApi; +import org.springframework.ai.qwen.api.QwenModel; +import org.springframework.ai.qwen.api.QwenSearchInfo; +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.core.convert.support.DefaultConversionService; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.Resource; +import org.springframework.util.MimeTypeUtils; + +import java.io.IOException; +import java.net.URL; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +import static java.util.Collections.singletonList; +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(classes = QwenChatModelIT.TestConfiguration.class) +@EnabledIfEnvironmentVariable(named = "DASHSCOPE_API_KEY", matches = ".+") +class QwenChatModelIT { + + private static final Logger logger = LoggerFactory.getLogger(QwenChatModelIT.class); + + @Autowired + private QwenChatModel chatModel; + + @Test + void roleTest() { + Message systemMessage = new SystemPromptTemplate(""" + You are a helpful AI assistant. Your name is {name}. + You are an AI assistant that helps people find information. + Your name is {name} + You should reply to the user's request with your name and also in the style of a {voice}. + """).createMessage(Map.of("name", "Bob", "voice", "pirate")); + + UserMessage userMessage = new UserMessage("Generate the names of 5 famous pirates."); + + Prompt prompt = new Prompt(List.of(userMessage, systemMessage)); + ChatResponse response = this.chatModel.call(prompt); + assertThat(response.getResult().getOutput().getText()).contains("Blackbeard"); + } + + @Test + void messageHistoryTest() { + + Message systemMessage = new SystemPromptTemplate(""" + You are a helpful AI assistant. Your name is {name}. + You are an AI assistant that helps people find information. + Your name is {name} + You should reply to the user's request with your name and also in the style of a {voice}. + """).createMessage(Map.of("name", "Bob", "voice", "pirate")); + + UserMessage userMessage = new UserMessage( + "Tell me about 3 famous pirates from the Golden Age of Piracy and why they did."); + + Prompt prompt = new Prompt(List.of(userMessage, systemMessage)); + + ChatResponse response = this.chatModel.call(prompt); + assertThat(response.getResult().getOutput().getText()).containsAnyOf("Blackbeard"); + + var promptWithMessageHistory = new Prompt(List.of(new UserMessage("Dummy"), response.getResult().getOutput(), + new UserMessage("Repeat the last assistant message."))); + response = this.chatModel.call(promptWithMessageHistory); + + System.out.println(response.getResult().getOutput().getText()); + assertThat(response.getResult().getOutput().getText()).containsAnyOf("Blackbeard"); + } + + @Test + void listOutputConverter() { + DefaultConversionService conversionService = new DefaultConversionService(); + ListOutputConverter outputConverter = new ListOutputConverter(conversionService); + + String format = outputConverter.getFormat(); + String template = """ + List five {subject} + {format} + """; + PromptTemplate promptTemplate = new PromptTemplate(template, + Map.of("subject", "ice cream flavors", "format", format)); + Prompt prompt = new Prompt(promptTemplate.createMessage()); + Generation generation = this.chatModel.call(prompt).getResult(); + + List list = outputConverter.convert(generation.getOutput().getText()); + assertThat(list).hasSize(5); + + } + + @Test + void mapOutputConverter() { + MapOutputConverter outputConverter = new MapOutputConverter(); + + String format = outputConverter.getFormat(); + String template = """ + Provide me a List of {subject} + {format} + """; + PromptTemplate promptTemplate = new PromptTemplate(template, + Map.of("subject", "an array of numbers from 1 to 9 under they key name 'numbers'", "format", format)); + Prompt prompt = new Prompt(promptTemplate.createMessage()); + Generation generation = this.chatModel.call(prompt).getResult(); + + Map result = outputConverter.convert(generation.getOutput().getText()); + assertThat(result.get("numbers")).isEqualTo(Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9)); + + } + + @Test + void beanOutputConverter() { + + BeanOutputConverter outputConverter = new BeanOutputConverter<>(ActorsFilms.class); + + String format = outputConverter.getFormat(); + String template = """ + Generate the filmography for a random actor. + {format} + """; + PromptTemplate promptTemplate = new PromptTemplate(template, Map.of("format", format)); + Prompt prompt = new Prompt(promptTemplate.createMessage()); + Generation generation = this.chatModel.call(prompt).getResult(); + + ActorsFilms actorsFilms = outputConverter.convert(generation.getOutput().getText()); + assertThat(actorsFilms.actor()).isNotNull(); + } + + @Test + void beanOutputConverterRecords() { + + BeanOutputConverter outputConverter = new BeanOutputConverter<>(ActorsFilmsRecord.class); + + String format = outputConverter.getFormat(); + String template = """ + Generate the filmography of 5 movies for Tom Hanks. + {format} + """; + PromptTemplate promptTemplate = new PromptTemplate(template, Map.of("format", format)); + Prompt prompt = new Prompt(promptTemplate.createMessage()); + Generation generation = this.chatModel.call(prompt).getResult(); + + ActorsFilmsRecord actorsFilms = outputConverter.convert(generation.getOutput().getText()); + logger.info(actorsFilms.toString()); + assertThat(actorsFilms.actor()).isEqualTo("Tom Hanks"); + assertThat(actorsFilms.movies()).hasSize(5); + } + + @Test + void beanStreamOutputConverterRecords() { + + BeanOutputConverter converter = new BeanOutputConverter<>(ActorsFilmsRecord.class); + + String format = converter.getFormat(); + String template = """ + Generate the filmography of 5 movies for Tom Hanks. + {format} + """; + PromptTemplate promptTemplate = new PromptTemplate(template, Map.of("format", format)); + Prompt prompt = new Prompt(promptTemplate.createMessage()); + + // @formatter:off + String generationTextFromStream = this.chatModel.stream(prompt) + .collectList() + .block() + .stream() + .map(ChatResponse::getResults) + .flatMap(List::stream) + .map(Generation::getOutput) + .map(AssistantMessage::getText) + .filter(Objects::nonNull) + .collect(Collectors.joining()); + // @formatter:on + + ActorsFilmsRecord actorsFilms = converter.convert(generationTextFromStream); + logger.info(actorsFilms.toString()); + assertThat(actorsFilms.actor()).isEqualTo("Tom Hanks"); + assertThat(actorsFilms.movies()).hasSize(5); + } + + @Test + void multiModalityImageUrl() throws IOException { + URL url = new URL("https://docs.spring.io/spring-ai/reference/_images/multimodal.test.png"); + + // @formatter:off + String response = ChatClient.create(this.chatModel).prompt() + .options(QwenChatOptions.builder().model(QwenModel.QWEN_VL_MAX.getName()).build()) + .user(u -> u.text("Explain what do you see on this picture?").media(MimeTypeUtils.IMAGE_PNG, url)) + .call() + .content(); + // @formatter:on + + logger.info(response); + assertThat(response).containsAnyOf("bananas", "apple", "bowl", "basket", "fruit stand"); + } + + @Test + void multiModalityImageResource() { + Resource resource = new ClassPathResource("multimodal.test.png"); + + // @formatter:off + String response = ChatClient.create(this.chatModel).prompt() + .options(QwenChatOptions.builder().model(QwenModel.QWEN_VL_MAX.getName()).build()) + .user(u -> u.text("Explain what do you see on this picture?").media(MimeTypeUtils.IMAGE_PNG, resource)) + .call() + .content(); + // @formatter:on + + assertThat(response).containsAnyOf("bananas", "apple", "bowl", "basket", "fruit stand"); + } + + @Test + void answerAfterSearch() { + // @formatter:off + QwenChatOptions options = QwenChatOptions.builder() + .enableSearch(true) + .searchOptions(QwenChatOptions.SearchOptions.builder() + .citationFormat("[]") + .enableCitation(true) + .enableSource(true) + .forcedSearch(true) + .searchStrategy("standard") + .build()) + .build(); + // @formatter:on + + Prompt prompt = new Prompt("What is the weather of Beijing?", options); + + ChatResponse response = chatModel.call(prompt); + System.out.println(response.getResult().getOutput().getText()); + ChatResponseMetadata metadata = response.getMetadata(); + QwenSearchInfo searchInfo = metadata.get("searchInfo"); + assertThat(searchInfo).isNotNull(); + assertThat(searchInfo.searchResults()).isNotEmpty(); + } + + @Test + void translateMessage() { + // @formatter:off + QwenChatOptions options = QwenChatOptions.builder() + .model(QwenModel.QWEN_MT_PLUS.getName()) + .translationOptions(QwenChatOptions.TranslationOptions.builder() + .sourceLang("English") + .targetLang("Chinese") + .terms(singletonList(QwenChatOptions.TranslationOptionTerm.builder() + .source("memory") + .target("内存") + .build())) + .domains("Translate into this IT domain style.") + .build()) + .build(); + // @formatter:on + + Prompt prompt = new Prompt("my memory", options); + + ChatResponse response = chatModel.call(prompt); + String chineseContent = response.getResult().getOutput().getText().trim(); + System.out.println(chineseContent); + assertThat(chineseContent).isEqualTo("我的内存"); + } + + record ActorsFilms(String actor, List movies) { + } + + record ActorsFilmsRecord(String actor, List movies) { + } + + @SpringBootConfiguration + public static class TestConfiguration { + + @Bean + public QwenApi qwenApi() { + return QwenApi.builder().apiKey(System.getenv("DASHSCOPE_API_KEY")).build(); + } + + @Bean + public QwenChatModel qwenChatModel(QwenApi qwenApi) { + return QwenChatModel.builder().qwenApi(qwenApi).build(); + } + + } + +} diff --git a/models/spring-ai-qwen/src/test/java/org/springframework/ai/qwen/QwenChatModelObservationIT.java b/models/spring-ai-qwen/src/test/java/org/springframework/ai/qwen/QwenChatModelObservationIT.java new file mode 100644 index 00000000000..18db586a189 --- /dev/null +++ b/models/spring-ai-qwen/src/test/java/org/springframework/ai/qwen/QwenChatModelObservationIT.java @@ -0,0 +1,177 @@ +package org.springframework.ai.qwen; + +import io.micrometer.observation.tck.TestObservationRegistry; +import io.micrometer.observation.tck.TestObservationRegistryAssert; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.platform.commons.logging.Logger; +import org.junit.platform.commons.logging.LoggerFactory; +import org.springframework.ai.chat.metadata.ChatResponseMetadata; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.observation.ChatModelObservationDocumentation; +import org.springframework.ai.chat.observation.DefaultChatModelObservationConvention; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.observation.conventions.AiOperationType; +import org.springframework.ai.observation.conventions.AiProvider; +import org.springframework.ai.qwen.api.QwenApi; +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 reactor.core.publisher.Flux; + +import java.util.List; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(classes = QwenChatModelObservationIT.TestConfiguration.class) +@EnabledIfEnvironmentVariable(named = "DASHSCOPE_API_KEY", matches = ".+") +public class QwenChatModelObservationIT { + + private static final Logger logger = LoggerFactory.getLogger(QwenChatModelObservationIT.class); + + @Autowired + private TestObservationRegistry observationRegistry; + + @Autowired + private QwenChatModel chatModel; + + @BeforeEach + void beforeEach() { + this.observationRegistry.clear(); + } + + @Test + void observationForImperativeChatOperation() { + + var options = QwenChatOptions.builder() + .frequencyPenalty(0.0) + .maxTokens(2048) + .presencePenalty(0.0) + .stopSequences(List.of("this-is-the-end")) + .temperature(0.7) + .topP(1.0) + .build(); + + Prompt prompt = new Prompt("Why does a raven look like a desk?", options); + + ChatResponse chatResponse = this.chatModel.call(prompt); + assertThat(chatResponse.getResult().getOutput().getText()).isNotEmpty(); + + ChatResponseMetadata responseMetadata = chatResponse.getMetadata(); + assertThat(responseMetadata).isNotNull(); + + validate(responseMetadata, true); + } + + @Test + void observationForStreamingChatOperation() { + + var options = QwenChatOptions.builder() + .frequencyPenalty(0.0) + .maxTokens(2048) + .presencePenalty(0.0) + .stopSequences(List.of("this-is-the-end")) + .temperature(0.7) + .topP(1.0) + .build(); + + Prompt prompt = new Prompt("Why does a raven look like a desk?", options); + + Flux chatResponseFlux = this.chatModel.stream(prompt); + List responses = chatResponseFlux.collectList().block(); + assertThat(responses).isNotEmpty(); + assertThat(responses).hasSizeGreaterThan(10); + + String aggregatedResponse = responses.subList(0, responses.size() - 1) + .stream() + .map(r -> r.getResult().getOutput().getText()) + .collect(Collectors.joining()); + assertThat(aggregatedResponse).isNotEmpty(); + + ChatResponse lastChatResponse = responses.get(responses.size() - 1); + + ChatResponseMetadata responseMetadata = lastChatResponse.getMetadata(); + assertThat(responseMetadata).isNotNull(); + + validate(responseMetadata, false); + } + + private void validate(ChatResponseMetadata responseMetadata, boolean checkModel) { + + TestObservationRegistryAssert.That that = TestObservationRegistryAssert.assertThat(this.observationRegistry) + .doesNotHaveAnyRemainingCurrentObservation() + .hasObservationWithNameEqualTo(DefaultChatModelObservationConvention.DEFAULT_NAME); + + if (checkModel) { + that.that() + .hasLowCardinalityKeyValue( + ChatModelObservationDocumentation.LowCardinalityKeyNames.RESPONSE_MODEL.asString(), + responseMetadata.getModel()); + } + + that.that() + .hasLowCardinalityKeyValue( + ChatModelObservationDocumentation.LowCardinalityKeyNames.AI_OPERATION_TYPE.asString(), + AiOperationType.CHAT.value()) + .hasLowCardinalityKeyValue(ChatModelObservationDocumentation.LowCardinalityKeyNames.AI_PROVIDER.asString(), + AiProvider.ALIBABA.value()) + .hasHighCardinalityKeyValue( + ChatModelObservationDocumentation.HighCardinalityKeyNames.REQUEST_FREQUENCY_PENALTY.asString(), + "0.0") + .hasHighCardinalityKeyValue( + ChatModelObservationDocumentation.HighCardinalityKeyNames.REQUEST_MAX_TOKENS.asString(), "2048") + .hasHighCardinalityKeyValue( + ChatModelObservationDocumentation.HighCardinalityKeyNames.REQUEST_PRESENCE_PENALTY.asString(), + "0.0") + .hasHighCardinalityKeyValue( + ChatModelObservationDocumentation.HighCardinalityKeyNames.REQUEST_STOP_SEQUENCES.asString(), + "[\"this-is-the-end\"]") + .hasHighCardinalityKeyValue( + ChatModelObservationDocumentation.HighCardinalityKeyNames.REQUEST_TEMPERATURE.asString(), "0.7") + .doesNotHaveHighCardinalityKeyValueWithKey( + ChatModelObservationDocumentation.HighCardinalityKeyNames.REQUEST_TOP_K.asString()) + .hasHighCardinalityKeyValue( + ChatModelObservationDocumentation.HighCardinalityKeyNames.REQUEST_TOP_P.asString(), "1.0") + .hasHighCardinalityKeyValue( + ChatModelObservationDocumentation.HighCardinalityKeyNames.RESPONSE_ID.asString(), + responseMetadata.getId()) + .hasHighCardinalityKeyValue( + ChatModelObservationDocumentation.HighCardinalityKeyNames.RESPONSE_FINISH_REASONS.asString(), + "[\"stop\"]") + .hasHighCardinalityKeyValue( + ChatModelObservationDocumentation.HighCardinalityKeyNames.USAGE_INPUT_TOKENS.asString(), + String.valueOf(responseMetadata.getUsage().getPromptTokens())) + .hasHighCardinalityKeyValue( + ChatModelObservationDocumentation.HighCardinalityKeyNames.USAGE_OUTPUT_TOKENS.asString(), + String.valueOf(responseMetadata.getUsage().getCompletionTokens())) + .hasHighCardinalityKeyValue( + ChatModelObservationDocumentation.HighCardinalityKeyNames.USAGE_TOTAL_TOKENS.asString(), + String.valueOf(responseMetadata.getUsage().getTotalTokens())) + .hasBeenStarted() + .hasBeenStopped(); + } + + @SpringBootConfiguration + public static class TestConfiguration { + + @Bean + public TestObservationRegistry observationRegistry() { + return TestObservationRegistry.create(); + } + + @Bean + public QwenApi qwenApi() { + return QwenApi.builder().apiKey(System.getenv("DASHSCOPE_API_KEY")).build(); + } + + @Bean + public QwenChatModel qwenChatModel(QwenApi qwenApi, TestObservationRegistry observationRegistry) { + return QwenChatModel.builder().qwenApi(qwenApi).observationRegistry(observationRegistry).build(); + } + + } + +} diff --git a/models/spring-ai-qwen/src/test/java/org/springframework/ai/qwen/QwenChatModelToolCallIT.java b/models/spring-ai-qwen/src/test/java/org/springframework/ai/qwen/QwenChatModelToolCallIT.java new file mode 100644 index 00000000000..17ab243c698 --- /dev/null +++ b/models/spring-ai-qwen/src/test/java/org/springframework/ai/qwen/QwenChatModelToolCallIT.java @@ -0,0 +1,192 @@ +package org.springframework.ai.qwen; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.chat.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.qwen.api.QwenApi; +import org.springframework.ai.tool.function.FunctionToolCallback; +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 reactor.core.publisher.Flux; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(classes = QwenChatModelToolCallIT.TestConfiguration.class) +@EnabledIfEnvironmentVariable(named = "DASHSCOPE_API_KEY", matches = ".+") +public class QwenChatModelToolCallIT { + + private static final Logger logger = LoggerFactory.getLogger(QwenChatModelIT.class); + + @Autowired + private QwenChatModel chatModel; + + private final MockWeatherService weatherService = new MockWeatherService(); + + @Test + void functionCallTest() { + + UserMessage userMessage = new UserMessage("What's the weather like in San Francisco, in Tokyo, and in Paris?"); + + List messages = new ArrayList<>(List.of(userMessage)); + + var promptOptions = QwenChatOptions.builder() + .toolCallbacks(List.of(FunctionToolCallback.builder("getCurrentWeather", new MockWeatherService()) + .description("Get the current weather in a given location") + .inputType(MockWeatherService.Request.class) + .build())) + .build(); + + ChatResponse response = this.chatModel.call(new Prompt(messages, promptOptions)); + + logger.info("Response: {}", response); + + assertThat(response.getResult()).isNotNull(); + assertThat(response.getResult().getOutput()).isNotNull(); + assertThat(response.getResult().getOutput().getText()).contains("30", "10", "15"); + assertThat(response.getMetadata()).isNotNull(); + assertThat(response.getMetadata().getUsage()).isNotNull(); + assertThat(response.getMetadata().getUsage().getTotalTokens()).isGreaterThan(600); + } + + @Test + void functionCallSequentialTest() { + + UserMessage userMessage = new UserMessage( + "What's the weather like in San Francisco? If the weather is above 25 degrees, please check the weather in Tokyo and Paris."); + + List messages = new ArrayList<>(List.of(userMessage)); + + var promptOptions = QwenChatOptions.builder() + .toolCallbacks(List.of(FunctionToolCallback.builder("getCurrentWeather", new MockWeatherService()) + .description("Get the current weather in a given location") + .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 + void streamFunctionCallTest() { + UserMessage userMessage = new UserMessage("What's the weather like in San Francisco, Tokyo, and Paris?"); + + List messages = new ArrayList<>(List.of(userMessage)); + + var promptOptions = QwenChatOptions.builder() + .toolCallbacks(List.of(FunctionToolCallback.builder("getCurrentWeather", new MockWeatherService()) + .description("Get the current weather in a given location") + .inputType(MockWeatherService.Request.class) + .build())) + .build(); + + Flux response = this.chatModel.stream(new Prompt(messages, promptOptions)); + + final var counter = new AtomicInteger(); + String content = response.doOnEach(listSignal -> counter.getAndIncrement()) + .collectList() + .block() + .stream() + .map(ChatResponse::getResults) + .flatMap(List::stream) + .map(Generation::getOutput) + .map(AssistantMessage::getText) + .collect(Collectors.joining()); + logger.info("Response: {}", content); + + assertThat(counter.get()).isGreaterThan(30).as("The response should be chunked in more than 30 messages"); + + assertThat(content).contains("30", "10", "15"); + + } + + @Test + void streamFunctionCallUsageTest() { + UserMessage userMessage = new UserMessage("What's the weather like in San Francisco, Tokyo, and Paris?"); + + List messages = new ArrayList<>(List.of(userMessage)); + + var promptOptions = QwenChatOptions.builder() + .toolCallbacks(List.of(FunctionToolCallback.builder("getCurrentWeather", new MockWeatherService()) + .description("Get the current weather in a given location") + .inputType(MockWeatherService.Request.class) + .build())) + .build(); + + Flux response = this.chatModel.stream(new Prompt(messages, promptOptions)); + + ChatResponse chatResponse = response.last().block(); + logger.info("Response: {}", chatResponse); + + assertThat(chatResponse.getMetadata().getUsage().getTotalTokens()).isGreaterThan(600); + + } + + @Test + void functionCallSequentialAndStreamTest() { + + UserMessage userMessage = new UserMessage( + "What's the weather like in San Francisco? If the weather is above 25 degrees, please check the weather in Tokyo and Paris."); + + List messages = new ArrayList<>(List.of(userMessage)); + + var promptOptions = QwenChatOptions.builder() + .toolCallbacks(List.of(FunctionToolCallback.builder("getCurrentWeather", new MockWeatherService()) + .description("Get the current weather in a given location") + .inputType(MockWeatherService.Request.class) + .build())) + .build(); + + var response = this.chatModel.stream(new Prompt(messages, promptOptions)); + + final var counter = new AtomicInteger(); + String content = response.doOnEach(listSignal -> counter.getAndIncrement()) + .collectList() + .block() + .stream() + .map(ChatResponse::getResults) + .flatMap(List::stream) + .map(Generation::getOutput) + .map(AssistantMessage::getText) + .filter(Objects::nonNull) + .collect(Collectors.joining()); + + logger.info("Response: {}", response); + + assertThat(content).contains("30", "10", "15"); + } + + @SpringBootConfiguration + public static class TestConfiguration { + + @Bean + public QwenApi qwenApi() { + return QwenApi.builder().apiKey(System.getenv("DASHSCOPE_API_KEY")).build(); + } + + @Bean + public QwenChatModel qwenChatModel(QwenApi qwenApi) { + return QwenChatModel.builder().qwenApi(qwenApi).build(); + } + + } + +} diff --git a/models/spring-ai-qwen/src/test/java/org/springframework/ai/qwen/aot/QwenRuntimeHintsTests.java b/models/spring-ai-qwen/src/test/java/org/springframework/ai/qwen/aot/QwenRuntimeHintsTests.java new file mode 100644 index 00000000000..31c500d51bb --- /dev/null +++ b/models/spring-ai-qwen/src/test/java/org/springframework/ai/qwen/aot/QwenRuntimeHintsTests.java @@ -0,0 +1,29 @@ +package org.springframework.ai.qwen.aot; + +import org.junit.jupiter.api.Test; +import org.springframework.ai.aot.AiRuntimeHints; +import org.springframework.aot.hint.RuntimeHints; +import org.springframework.aot.hint.TypeReference; + +import java.util.Set; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.springframework.aot.hint.predicate.RuntimeHintsPredicates.reflection; + +public class QwenRuntimeHintsTests { + + @Test + void registerHints() { + RuntimeHints runtimeHints = new RuntimeHints(); + QwenRuntimeHints qwenRuntimeHints = new QwenRuntimeHints(); + qwenRuntimeHints.registerHints(runtimeHints, null); + + Set qwenModelTypes = AiRuntimeHints.findClassesInPackage( + com.alibaba.dashscope.Version.class.getPackageName(), (metadataReader, metadataReaderFactory) -> true); + assertThat(qwenModelTypes.size()).isGreaterThan(100); + for (TypeReference modelType : qwenModelTypes) { + assertThat(runtimeHints).matches(reflection().onType(modelType)); + } + } + +} diff --git a/models/spring-ai-qwen/src/test/java/org/springframework/ai/qwen/api/MockImageContentFilter.java b/models/spring-ai-qwen/src/test/java/org/springframework/ai/qwen/api/MockImageContentFilter.java new file mode 100644 index 00000000000..fc0dc8637be --- /dev/null +++ b/models/spring-ai-qwen/src/test/java/org/springframework/ai/qwen/api/MockImageContentFilter.java @@ -0,0 +1,52 @@ +package org.springframework.ai.qwen.api; + +import com.alibaba.dashscope.aigc.multimodalconversation.MultiModalConversationParam; +import com.alibaba.dashscope.common.MultiModalMessage; +import org.springframework.util.CollectionUtils; +import org.springframework.util.StringUtils; + +import java.util.LinkedList; +import java.util.List; +import java.util.Map; + +public class MockImageContentFilter { + + static void handle(MultiModalConversationParam.MultiModalConversationParamBuilder builder) { + List> filteredContents = new LinkedList<>(); + List filteredMessages = new LinkedList<>(); + boolean customized = false; + + for (Object message : builder.build().getMessages()) { + MultiModalMessage multiModalMessage = (MultiModalMessage) message; + for (Map content : multiModalMessage.getContent()) { + Map filteredContent = CollectionUtils.newHashMap(1); + for (String key : content.keySet()) { + Object value = content.get(key); + if ("image".equals(key)) { + String imageUrl = (String) content.get("image"); + if (StringUtils.hasText(imageUrl)) { + // Maybe an invalid image. Replace with a default one. + value = "https://avatars.githubusercontent.com/u/317776"; + customized = true; + } + } + filteredContent.put(key, value); + } + filteredContents.add(filteredContent); + } + // @formatter:off + MultiModalMessage filteredMessage = MultiModalMessage.builder() + .role(multiModalMessage.getRole()) + .content(filteredContents) + .build(); + // @formatter:on + filteredMessages.add(filteredMessage); + } + + if (customized) { + builder.clearMessages(); + builder.messages(filteredMessages); + } + } + +} diff --git a/models/spring-ai-qwen/src/test/java/org/springframework/ai/qwen/api/QwenApiIT.java b/models/spring-ai-qwen/src/test/java/org/springframework/ai/qwen/api/QwenApiIT.java new file mode 100644 index 00000000000..b3e9e870f69 --- /dev/null +++ b/models/spring-ai-qwen/src/test/java/org/springframework/ai/qwen/api/QwenApiIT.java @@ -0,0 +1,156 @@ +package org.springframework.ai.qwen.api; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.SystemMessage; +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.content.Media; +import org.springframework.ai.qwen.QwenChatOptions; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.Resource; +import org.springframework.util.MimeTypeUtils; + +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; + +@EnabledIfEnvironmentVariable(named = "DASHSCOPE_API_KEY", matches = ".+") +public class QwenApiIT { + + private static final Logger logger = LoggerFactory.getLogger(QwenApiIT.class); + + private QwenApi qwenApi() { + return QwenApi.builder().apiKey(System.getenv("DASHSCOPE_API_KEY")).build(); + } + + private List history() { + SystemMessage systemMessage = new SystemMessage(""" + Your name is Jack. + You like to answer other people's questions briefly. + It's rainy today. + """); + + UserMessage query1 = new UserMessage("Hello. What's your name?"); + AssistantMessage answer = new AssistantMessage("Jack!"); + UserMessage query2 = new UserMessage("How about the weather today?"); + + return List.of(systemMessage, query1, answer, query2); + } + + @Test + public void callNonMultimodalModel() { + QwenChatOptions options = QwenChatOptions.builder().model(QwenModel.QWEN_MAX.getName()).build(); + Prompt prompt = new Prompt(history(), options); + + QwenApi api = qwenApi(); + + ChatResponse response = api.call(prompt, null); + logger.info(response.getResult().getOutput().getText()); + assertThat(response.getResult().getOutput().getText()).containsIgnoringCase("rain"); + } + + @Test + public void streamingCallNonMultimodalModel() { + QwenChatOptions options = QwenChatOptions.builder().model(QwenModel.QWEN_MAX.getName()).build(); + Prompt prompt = new Prompt(history(), options); + + QwenApi api = qwenApi(); + + // @formatter:off + String generationTextFromStream = api.streamCall(prompt, null) + .collectList() + .block() + .stream() + .map(ChatResponse::getResults) + .flatMap(List::stream) + .map(Generation::getOutput) + .map(AssistantMessage::getText) + .filter(Objects::nonNull) + .collect(Collectors.joining()); + // @formatter:on + + logger.info(generationTextFromStream); + assertThat(generationTextFromStream).containsIgnoringCase("rain"); + } + + @Test + public void callMultimodalModel() { + QwenChatOptions options = QwenChatOptions.builder().model(QwenModel.QWEN_VL_MAX.getName()).build(); + Resource resource = new ClassPathResource("multimodal.test.png"); + UserMessage message = new UserMessage("Explain what do you see on this picture?", + Media.builder().mimeType(MimeTypeUtils.IMAGE_PNG).data(resource).build()); + Prompt prompt = new Prompt(message, options); + + QwenApi api = qwenApi(); + + ChatResponse response = api.call(prompt, null); + logger.info(response.getResult().getOutput().getText()); + assertThat(response.getResult().getOutput().getText()).containsAnyOf("bananas", "apple", "bowl", "basket", + "fruit stand"); + } + + @Test + public void callNonMultimodalModelWithCustomizedParameter() { + QwenChatOptions options = QwenChatOptions.builder().model(QwenModel.QWEN_MAX.getName()).build(); + Prompt prompt = new Prompt(history(), options); + + QwenApi api = qwenApi(); + api.setGenerationParamCustomizer(builder -> builder.stopString("rain")); + + ChatResponse response = api.call(prompt, null); + logger.info(response.getResult().getOutput().getText()); + assertThat(response.getResult().getOutput().getText()).doesNotContainIgnoringCase("rain"); + } + + @Test + public void streamingCallNonMultimodalModelWithCustomizedParameter() { + QwenChatOptions options = QwenChatOptions.builder().model(QwenModel.QWEN_MAX.getName()).build(); + Prompt prompt = new Prompt(history(), options); + + QwenApi api = qwenApi(); + api.setGenerationParamCustomizer(builder -> builder.stopString("rain")); + + // @formatter:off + String generationTextFromStream = api.streamCall(prompt, null) + .collectList() + .block() + .stream() + .map(ChatResponse::getResults) + .flatMap(List::stream) + .map(Generation::getOutput) + .map(AssistantMessage::getText) + .filter(Objects::nonNull) + .collect(Collectors.joining()); + // @formatter:on + + logger.info(generationTextFromStream); + assertThat(generationTextFromStream).doesNotContainIgnoringCase("rain"); + } + + @Test + public void callMultimodalModelWithCustomizedParameter() { + QwenChatOptions options = QwenChatOptions.builder().model(QwenModel.QWEN_VL_MAX.getName()).build(); + Resource resource = new ClassPathResource("multimodal.test.png"); + UserMessage message = new UserMessage("Explain what do you see on this picture?", + Media.builder().mimeType(MimeTypeUtils.IMAGE_PNG).data(resource).build()); + Prompt prompt = new Prompt(message, options); + + QwenApi api = qwenApi(); + api.setMultimodalConversationParamCustomizer(MockImageContentFilter::handle); + + ChatResponse response = api.call(prompt, null); + logger.info(response.getResult().getOutput().getText()); + assertThat(response.getResult().getOutput().getText()).doesNotContainIgnoringCase("bananas", "apple", "bowl", + "basket", "fruit stand"); + } + +} diff --git a/models/spring-ai-qwen/src/test/resources/multimodal.test.png b/models/spring-ai-qwen/src/test/resources/multimodal.test.png new file mode 100644 index 0000000000000000000000000000000000000000..4f898454121115b347d7ae0b5fd50e50ab8b319a GIT binary patch literal 123394 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 literal 0 HcmV?d00001 diff --git a/pom.xml b/pom.xml index e4927004b8d..64618ba9754 100644 --- a/pom.xml +++ b/pom.xml @@ -176,6 +176,7 @@ models/spring-ai-watsonx-ai models/spring-ai-zhipuai models/spring-ai-moonshot + models/spring-ai-qwen spring-ai-spring-boot-starters/spring-ai-starter-model-anthropic spring-ai-spring-boot-starters/spring-ai-starter-model-azure-openai diff --git a/spring-ai-bom/pom.xml b/spring-ai-bom/pom.xml index e163de24f9f..e791eb30207 100644 --- a/spring-ai-bom/pom.xml +++ b/spring-ai-bom/pom.xml @@ -276,6 +276,12 @@ ${project.version} + + org.springframework.ai + spring-ai-qwen + ${project.version} + + diff --git a/spring-ai-commons/src/main/java/org/springframework/ai/observation/conventions/AiProvider.java b/spring-ai-commons/src/main/java/org/springframework/ai/observation/conventions/AiProvider.java index e723b679b02..3278e4feb92 100644 --- a/spring-ai-commons/src/main/java/org/springframework/ai/observation/conventions/AiProvider.java +++ b/spring-ai-commons/src/main/java/org/springframework/ai/observation/conventions/AiProvider.java @@ -99,7 +99,12 @@ public enum AiProvider { /** * AI system provided by ONNX. */ - ONNX("onnx"); + ONNX("onnx"), + + /** + * AI system provided by Alibaba + */ + ALIBABA("alibaba"); private final String value; From 7aec508046381fb9453ce441c3b53048c63916b5 Mon Sep 17 00:00:00 2001 From: jiangsier-xyz <126842484+jiangsier-xyz@users.noreply.github.com> Date: Tue, 22 Apr 2025 12:18:06 +0800 Subject: [PATCH 02/13] chore: format Signed-off-by: jiangsier-xyz <126842484+jiangsier-xyz@users.noreply.github.com> --- .../ai/qwen/QwenChatModel.java | 54 +++++------ .../ai/qwen/QwenChatModelIT.java | 94 +++++++++---------- .../ai/qwen/api/MockImageContentFilter.java | 10 +- .../ai/qwen/api/QwenApiIT.java | 44 ++++----- 4 files changed, 93 insertions(+), 109 deletions(-) diff --git a/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/QwenChatModel.java b/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/QwenChatModel.java index ba38299b174..55d24f96542 100644 --- a/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/QwenChatModel.java +++ b/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/QwenChatModel.java @@ -145,33 +145,33 @@ private Flux internalStream(Prompt prompt, ChatResponse previousCh observation.parentObservation(contextView.getOrDefault(ObservationThreadLocalAccessor.KEY, null)).start(); - // @formatter:off - Flux chatResponse = this.qwenApi.streamCall(prompt, previousChatResponse) - .flatMap(response -> { - if (this.toolExecutionEligibilityPredicate.isToolExecutionRequired(prompt.getOptions(), response)) { - return Flux.defer(() -> { - 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()), - response); - } - }).subscribeOn(Schedulers.boundedElastic()); - } - else { - return Flux.just(response); - } - }) - .doOnError(observation::error) - .doFinally(s -> observation.stop()) - .contextWrite(ctx -> ctx.put(ObservationThreadLocalAccessor.KEY, observation)); - // @formatter:on + Flux chatResponse = this.qwenApi.streamCall(prompt, previousChatResponse) + .flatMap(response -> { + if (this.toolExecutionEligibilityPredicate.isToolExecutionRequired(prompt.getOptions(), response)) { + return Flux.defer(() -> { + 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()), + response); + } + }).subscribeOn(Schedulers.boundedElastic()); + } + else { + return Flux.just(response); + } + }) + .doOnError(observation::error) + .doFinally(s -> observation.stop()) + .contextWrite(ctx -> ctx.put(ObservationThreadLocalAccessor.KEY, observation)); return new MessageAggregator().aggregate(chatResponse, observationContext::setResponse); }); diff --git a/models/spring-ai-qwen/src/test/java/org/springframework/ai/qwen/QwenChatModelIT.java b/models/spring-ai-qwen/src/test/java/org/springframework/ai/qwen/QwenChatModelIT.java index 52f7100deaa..24d3d17a439 100644 --- a/models/spring-ai-qwen/src/test/java/org/springframework/ai/qwen/QwenChatModelIT.java +++ b/models/spring-ai-qwen/src/test/java/org/springframework/ai/qwen/QwenChatModelIT.java @@ -181,18 +181,16 @@ void beanStreamOutputConverterRecords() { PromptTemplate promptTemplate = new PromptTemplate(template, Map.of("format", format)); Prompt prompt = new Prompt(promptTemplate.createMessage()); - // @formatter:off - String generationTextFromStream = this.chatModel.stream(prompt) - .collectList() - .block() - .stream() - .map(ChatResponse::getResults) - .flatMap(List::stream) - .map(Generation::getOutput) - .map(AssistantMessage::getText) - .filter(Objects::nonNull) - .collect(Collectors.joining()); - // @formatter:on + String generationTextFromStream = this.chatModel.stream(prompt) + .collectList() + .block() + .stream() + .map(ChatResponse::getResults) + .flatMap(List::stream) + .map(Generation::getOutput) + .map(AssistantMessage::getText) + .filter(Objects::nonNull) + .collect(Collectors.joining()); ActorsFilmsRecord actorsFilms = converter.convert(generationTextFromStream); logger.info(actorsFilms.toString()); @@ -204,13 +202,12 @@ void beanStreamOutputConverterRecords() { void multiModalityImageUrl() throws IOException { URL url = new URL("https://docs.spring.io/spring-ai/reference/_images/multimodal.test.png"); - // @formatter:off - String response = ChatClient.create(this.chatModel).prompt() - .options(QwenChatOptions.builder().model(QwenModel.QWEN_VL_MAX.getName()).build()) - .user(u -> u.text("Explain what do you see on this picture?").media(MimeTypeUtils.IMAGE_PNG, url)) - .call() - .content(); - // @formatter:on + String response = ChatClient.create(this.chatModel) + .prompt() + .options(QwenChatOptions.builder().model(QwenModel.QWEN_VL_MAX.getName()).build()) + .user(u -> u.text("Explain what do you see on this picture?").media(MimeTypeUtils.IMAGE_PNG, url)) + .call() + .content(); logger.info(response); assertThat(response).containsAnyOf("bananas", "apple", "bowl", "basket", "fruit stand"); @@ -220,31 +217,28 @@ void multiModalityImageUrl() throws IOException { void multiModalityImageResource() { Resource resource = new ClassPathResource("multimodal.test.png"); - // @formatter:off - String response = ChatClient.create(this.chatModel).prompt() - .options(QwenChatOptions.builder().model(QwenModel.QWEN_VL_MAX.getName()).build()) - .user(u -> u.text("Explain what do you see on this picture?").media(MimeTypeUtils.IMAGE_PNG, resource)) - .call() - .content(); - // @formatter:on + String response = ChatClient.create(this.chatModel) + .prompt() + .options(QwenChatOptions.builder().model(QwenModel.QWEN_VL_MAX.getName()).build()) + .user(u -> u.text("Explain what do you see on this picture?").media(MimeTypeUtils.IMAGE_PNG, resource)) + .call() + .content(); assertThat(response).containsAnyOf("bananas", "apple", "bowl", "basket", "fruit stand"); } @Test void answerAfterSearch() { - // @formatter:off - QwenChatOptions options = QwenChatOptions.builder() - .enableSearch(true) - .searchOptions(QwenChatOptions.SearchOptions.builder() - .citationFormat("[]") - .enableCitation(true) - .enableSource(true) - .forcedSearch(true) - .searchStrategy("standard") - .build()) - .build(); - // @formatter:on + QwenChatOptions options = QwenChatOptions.builder() + .enableSearch(true) + .searchOptions(QwenChatOptions.SearchOptions.builder() + .citationFormat("[]") + .enableCitation(true) + .enableSource(true) + .forcedSearch(true) + .searchStrategy("standard") + .build()) + .build(); Prompt prompt = new Prompt("What is the weather of Beijing?", options); @@ -258,20 +252,16 @@ void answerAfterSearch() { @Test void translateMessage() { - // @formatter:off - QwenChatOptions options = QwenChatOptions.builder() - .model(QwenModel.QWEN_MT_PLUS.getName()) - .translationOptions(QwenChatOptions.TranslationOptions.builder() - .sourceLang("English") - .targetLang("Chinese") - .terms(singletonList(QwenChatOptions.TranslationOptionTerm.builder() - .source("memory") - .target("内存") - .build())) - .domains("Translate into this IT domain style.") - .build()) - .build(); - // @formatter:on + QwenChatOptions options = QwenChatOptions.builder() + .model(QwenModel.QWEN_MT_PLUS.getName()) + .translationOptions(QwenChatOptions.TranslationOptions.builder() + .sourceLang("English") + .targetLang("Chinese") + .terms(singletonList( + QwenChatOptions.TranslationOptionTerm.builder().source("memory").target("内存").build())) + .domains("Translate into this IT domain style.") + .build()) + .build(); Prompt prompt = new Prompt("my memory", options); diff --git a/models/spring-ai-qwen/src/test/java/org/springframework/ai/qwen/api/MockImageContentFilter.java b/models/spring-ai-qwen/src/test/java/org/springframework/ai/qwen/api/MockImageContentFilter.java index fc0dc8637be..ed16c9ccfed 100644 --- a/models/spring-ai-qwen/src/test/java/org/springframework/ai/qwen/api/MockImageContentFilter.java +++ b/models/spring-ai-qwen/src/test/java/org/springframework/ai/qwen/api/MockImageContentFilter.java @@ -34,12 +34,10 @@ static void handle(MultiModalConversationParam.MultiModalConversationParamBuilde } filteredContents.add(filteredContent); } - // @formatter:off - MultiModalMessage filteredMessage = MultiModalMessage.builder() - .role(multiModalMessage.getRole()) - .content(filteredContents) - .build(); - // @formatter:on + MultiModalMessage filteredMessage = MultiModalMessage.builder() + .role(multiModalMessage.getRole()) + .content(filteredContents) + .build(); filteredMessages.add(filteredMessage); } diff --git a/models/spring-ai-qwen/src/test/java/org/springframework/ai/qwen/api/QwenApiIT.java b/models/spring-ai-qwen/src/test/java/org/springframework/ai/qwen/api/QwenApiIT.java index b3e9e870f69..c52cda508ba 100644 --- a/models/spring-ai-qwen/src/test/java/org/springframework/ai/qwen/api/QwenApiIT.java +++ b/models/spring-ai-qwen/src/test/java/org/springframework/ai/qwen/api/QwenApiIT.java @@ -65,18 +65,16 @@ public void streamingCallNonMultimodalModel() { QwenApi api = qwenApi(); - // @formatter:off - String generationTextFromStream = api.streamCall(prompt, null) - .collectList() - .block() - .stream() - .map(ChatResponse::getResults) - .flatMap(List::stream) - .map(Generation::getOutput) - .map(AssistantMessage::getText) - .filter(Objects::nonNull) - .collect(Collectors.joining()); - // @formatter:on + String generationTextFromStream = api.streamCall(prompt, null) + .collectList() + .block() + .stream() + .map(ChatResponse::getResults) + .flatMap(List::stream) + .map(Generation::getOutput) + .map(AssistantMessage::getText) + .filter(Objects::nonNull) + .collect(Collectors.joining()); logger.info(generationTextFromStream); assertThat(generationTextFromStream).containsIgnoringCase("rain"); @@ -119,18 +117,16 @@ public void streamingCallNonMultimodalModelWithCustomizedParameter() { QwenApi api = qwenApi(); api.setGenerationParamCustomizer(builder -> builder.stopString("rain")); - // @formatter:off - String generationTextFromStream = api.streamCall(prompt, null) - .collectList() - .block() - .stream() - .map(ChatResponse::getResults) - .flatMap(List::stream) - .map(Generation::getOutput) - .map(AssistantMessage::getText) - .filter(Objects::nonNull) - .collect(Collectors.joining()); - // @formatter:on + String generationTextFromStream = api.streamCall(prompt, null) + .collectList() + .block() + .stream() + .map(ChatResponse::getResults) + .flatMap(List::stream) + .map(Generation::getOutput) + .map(AssistantMessage::getText) + .filter(Objects::nonNull) + .collect(Collectors.joining()); logger.info(generationTextFromStream); assertThat(generationTextFromStream).doesNotContainIgnoringCase("rain"); From 5c44949252385b5c48d68c3d614559a412fffe59 Mon Sep 17 00:00:00 2001 From: jiangsier-xyz <126842484+jiangsier-xyz@users.noreply.github.com> Date: Wed, 23 Apr 2025 12:08:43 +0800 Subject: [PATCH 03/13] refactor: ignore FunctionCallingOptions Signed-off-by: jiangsier-xyz <126842484+jiangsier-xyz@users.noreply.github.com> --- .../ai/qwen/QwenChatModel.java | 5 -- .../ai/qwen/QwenChatOptions.java | 51 +++++-------------- .../springframework/ai/qwen/api/QwenApi.java | 2 +- 3 files changed, 14 insertions(+), 44 deletions(-) diff --git a/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/QwenChatModel.java b/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/QwenChatModel.java index 55d24f96542..541f486b624 100644 --- a/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/QwenChatModel.java +++ b/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/QwenChatModel.java @@ -13,7 +13,6 @@ 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.FunctionCallingOptions; import org.springframework.ai.model.tool.DefaultToolExecutionEligibilityPredicate; import org.springframework.ai.model.tool.ToolCallingChatOptions; import org.springframework.ai.model.tool.ToolCallingManager; @@ -185,10 +184,6 @@ private Prompt buildRequestPrompt(Prompt prompt) { runtimeOptions = ModelOptionsUtils.copyToTarget(toolCallingChatOptions, ToolCallingChatOptions.class, QwenChatOptions.class); } - else if (prompt.getOptions() instanceof FunctionCallingOptions functionCallingOptions) { - runtimeOptions = ModelOptionsUtils.copyToTarget(functionCallingOptions, FunctionCallingOptions.class, - QwenChatOptions.class); - } else { runtimeOptions = ModelOptionsUtils.copyToTarget(prompt.getOptions(), ChatOptions.class, QwenChatOptions.class); diff --git a/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/QwenChatOptions.java b/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/QwenChatOptions.java index 1b139e36ce5..43ba1f03a07 100644 --- a/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/QwenChatOptions.java +++ b/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/QwenChatOptions.java @@ -1,7 +1,6 @@ package org.springframework.ai.qwen; import com.alibaba.dashscope.common.ResponseFormat; -import org.springframework.ai.model.function.FunctionCallback; import org.springframework.ai.model.tool.ToolCallingChatOptions; import org.springframework.ai.tool.ToolCallback; import org.springframework.lang.Nullable; @@ -101,7 +100,7 @@ public class QwenChatOptions implements ToolCallingChatOptions { * Collection of {@link ToolCallback}s to be used for tool calling in the chat * completion requests. */ - private List toolCallbacks; + private List toolCallbacks; /** * Collection of tool names to be resolved at runtime and used for tool calling in the @@ -155,7 +154,7 @@ public class QwenChatOptions implements ToolCallingChatOptions { * not specified, it will be judged based on the model name when called, but these * judgments may not keep up with the latest situation. */ - private Boolean multimodalModel; + private Boolean isMultimodalModel; /** * Whether the model supports incremental output in the streaming output mode. This @@ -190,7 +189,7 @@ private QwenChatOptions(Builder builder) { this.searchOptions = builder.searchOptions; this.translationOptions = builder.translationOptions; this.vlHighResolutionImages = builder.vlHighResolutionImages; - this.multimodalModel = builder.multimodalModel; + this.isMultimodalModel = builder.multimodalModel; this.custom = builder.custom; } @@ -269,29 +268,17 @@ public Double getTopP() { } @Override - public List getToolCallbacks() { + public List getToolCallbacks() { return this.toolCallbacks; } @Override - public void setToolCallbacks(List toolCallbacks) { + public void setToolCallbacks(List toolCallbacks) { Assert.notNull(toolCallbacks, "toolCallbacks cannot be null"); Assert.noNullElements(toolCallbacks, "toolCallbacks cannot contain null elements"); this.toolCallbacks = toolCallbacks; } - @Override - @Deprecated - public List getFunctionCallbacks() { - return this.getToolCallbacks(); - } - - @Override - @Deprecated - public void setFunctionCallbacks(List functionCallbacks) { - this.setToolCallbacks(functionCallbacks); - } - @Override public Set getToolNames() { return this.toolNames; @@ -305,21 +292,9 @@ public void setToolNames(Set toolNames) { this.toolNames = toolNames; } - @Override - @Deprecated - public Set getFunctions() { - return this.getToolNames(); - } - - @Override - @Deprecated - public void setFunctions(Set functionNames) { - this.setToolNames(functionNames); - } - @Override @Nullable - public Boolean isInternalToolExecutionEnabled() { + public Boolean getInternalToolExecutionEnabled() { return internalToolExecutionEnabled; } @@ -391,12 +366,12 @@ public void setVlHighResolutionImages(Boolean vlHighResolutionImages) { this.vlHighResolutionImages = vlHighResolutionImages; } - public Boolean isMultimodalModel() { - return multimodalModel; + public Boolean getIsMultimodalModel() { + return isMultimodalModel; } - public void setMultimodalModel(Boolean multimodalModel) { - this.multimodalModel = multimodalModel; + public void setIsMultimodalModel(Boolean isMultimodalModel) { + this.isMultimodalModel = isMultimodalModel; } public Boolean getSupportIncrementalOutput() { @@ -454,7 +429,7 @@ public static class Builder { private Integer topK; - private List toolCallbacks = new ArrayList<>(); + private List toolCallbacks = new ArrayList<>(); private Set toolNames = new HashSet<>(); @@ -528,7 +503,7 @@ public Builder topK(Integer topK) { return this; } - public Builder toolCallbacks(List toolCallbacks) { + public Builder toolCallbacks(List toolCallbacks) { this.toolCallbacks = toolCallbacks; return this; } @@ -614,7 +589,7 @@ public Builder overrideWith(QwenChatOptions fromOptions) { this.translationOptions(getOrDefault(fromOptions.getTranslationOptions(), this.translationOptions)); this.vlHighResolutionImages( getOrDefault(fromOptions.getVlHighResolutionImages(), this.vlHighResolutionImages)); - this.isMultimodalModel(getOrDefault(fromOptions.isMultimodalModel(), this.multimodalModel)); + this.isMultimodalModel(getOrDefault(fromOptions.getIsMultimodalModel(), this.multimodalModel)); this.supportIncrementalOutput( getOrDefault(fromOptions.getSupportIncrementalOutput(), this.supportIncrementalOutput)); this.custom(copyIfNotNull(getOrDefault(fromOptions.getCustom(), this.custom))); diff --git a/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/api/QwenApi.java b/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/api/QwenApi.java index 6c20d57f511..553b0c617fe 100644 --- a/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/api/QwenApi.java +++ b/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/api/QwenApi.java @@ -266,7 +266,7 @@ boolean isMultimodalModel(Prompt prompt) { } String modelName = options.getModel(); - Boolean isMultimodalModel = ((QwenChatOptions) options).isMultimodalModel(); + Boolean isMultimodalModel = ((QwenChatOptions) options).getIsMultimodalModel(); isMultimodalModel = getOrDefault(isMultimodalModel, isMultimodalModelName(modelName)); return Boolean.TRUE.equals(isMultimodalModel); From 5919c316e8be7f5d40a00d2601e2f065df0dd11d Mon Sep 17 00:00:00 2001 From: jiangsier-xyz <126842484+jiangsier-xyz@users.noreply.github.com> Date: Wed, 23 Apr 2025 12:25:18 +0800 Subject: [PATCH 04/13] refactor: ignore FunctionCallback Signed-off-by: jiangsier-xyz <126842484+jiangsier-xyz@users.noreply.github.com> --- .../ai/qwen/api/QwenApiHelper.java | 51 ++++--------------- 1 file changed, 11 insertions(+), 40 deletions(-) diff --git a/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/api/QwenApiHelper.java b/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/api/QwenApiHelper.java index cdf83629970..7830804d416 100644 --- a/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/api/QwenApiHelper.java +++ b/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/api/QwenApiHelper.java @@ -39,8 +39,8 @@ import org.springframework.ai.chat.model.Generation; import org.springframework.ai.chat.prompt.Prompt; import org.springframework.ai.content.Media; -import org.springframework.ai.model.function.FunctionCallback; import org.springframework.ai.qwen.QwenChatOptions; +import org.springframework.ai.tool.ToolCallback; import org.springframework.util.CollectionUtils; import org.springframework.util.MimeType; import org.springframework.util.StringUtils; @@ -148,7 +148,7 @@ private static ToolCallBase toToolCall(AssistantMessage.ToolCall toolExecutionRe return toolCallFunction; } - private static List toToolFunctions(Collection toolSpecifications) { + private static List toToolFunctions(Collection toolSpecifications) { if (CollectionUtils.isEmpty(toolSpecifications)) { return Collections.emptyList(); } @@ -156,18 +156,18 @@ private static List toToolFunctions(Collection toolS return toolSpecifications.stream().map(QwenApiHelper::toToolFunction).toList(); } - private static ToolBase toToolFunction(FunctionCallback toolCallback) { + private static ToolBase toToolFunction(ToolCallback toolCallback) { FunctionDefinition functionDefinition = FunctionDefinition.builder() - .name(toolCallback.getName()) - .description(getOrDefault(toolCallback.getDescription(), "")) + .name(toolCallback.getToolDefinition().name()) + .description(getOrDefault(toolCallback.getToolDefinition().description(), "")) .parameters(toParameters(toolCallback)) .build(); return ToolFunction.builder().function(functionDefinition).build(); } - private static JsonObject toParameters(FunctionCallback toolCallback) { - if (toolCallback.getInputTypeSchema() != null) { - return JsonUtils.parse(toolCallback.getInputTypeSchema()); + private static JsonObject toParameters(ToolCallback toolCallback) { + if (StringUtils.hasText(toolCallback.getToolDefinition().inputSchema())) { + return JsonUtils.parse(toolCallback.getToolDefinition().inputSchema()); } else { return JsonUtils.toJsonObject(Collections.emptyMap()); @@ -196,9 +196,8 @@ private static List> toMultiModalContents(Message message) { media.stream().map(QwenApiHelper::toMultiModalContent).forEach(contents::add); - if (message.getMessageType() == MessageType.TOOL) { - ToolResponseMessage toolMessage = (ToolResponseMessage) message; - List toolResponses = toolMessage.getResponses(); + if (message instanceof ToolResponseMessage toolMessage) { + List toolResponses = toolMessage.getResponses(); if (!CollectionUtils.isEmpty(toolResponses)) { for (ToolResponseMessage.ToolResponse toolResponse : toolResponses) { contents.add(Map.of("content", toolResponse.responseData(), "tool_call_id", toolResponse.id())); @@ -363,7 +362,7 @@ static GenerationParam toGenerationParam(String apiKey, Prompt prompt, boolean i builder.tools(toToolFunctions(options.getToolCallbacks())); if (options.getToolChoice() != null) { Object toolChoiceObject = options.getToolChoice(); - if (toolChoiceObject instanceof FunctionCallback toolCallback) { + if (toolChoiceObject instanceof ToolCallback toolCallback) { builder.toolChoice(toToolFunction(toolCallback)); } else { @@ -820,38 +819,10 @@ else if (frequencyPenalty < -2) { return (float) (logit(x) / denominator); } - static Double repetitionPenaltyToFrequencyPenalty(Float repetitionPenalty) { - // repetitionPenalty: - // https://www.alibabacloud.com/help/en/model-studio/use-qwen-by-calling-api#2ed5ee7377fum - // frequencyPenalty: - // https://platform.openai.com/docs/api-reference/chat/create#chat-create-frequency_penalty - // map: (0, ∞) -> [-2, 2], and 1 -> 0 - // use sigmoid function (https://en.wikipedia.org/wiki/Sigmoid_function) - - if (repetitionPenalty == null) { - return null; - } - else if (repetitionPenalty <= 0) { - throw new IllegalArgumentException("Value of repetitionPenalty must be positive number"); - } - - // make sure frequency penalty is 0 when repetition penalty is 1 - // see frequencyPenaltyToRepetitionPenalty() - double factor = logit(0.75d); - double y = sigmoid(repetitionPenalty.doubleValue() * factor); - - // make sure frequency penalty is between -2 and 2 - return y * 8 - 6; - } - private static double logit(double x) { return Math.log(x / (1 - x)); } - private static double sigmoid(double x) { - return 1.0 / (1.0 + Math.exp(-x)); - } - static List generationsFrom(GenerationResult result) { return Optional.of(result) .map(GenerationResult::getOutput) From 90d0f07b25616d95ef565dfa86465f8d7bd938b7 Mon Sep 17 00:00:00 2001 From: jiangsier-xyz <126842484+jiangsier-xyz@users.noreply.github.com> Date: Wed, 23 Apr 2025 15:38:19 +0800 Subject: [PATCH 05/13] chore: enhanced robustness Signed-off-by: jiangsier-xyz <126842484+jiangsier-xyz@users.noreply.github.com> --- .../org/springframework/ai/qwen/api/QwenApiHelper.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/api/QwenApiHelper.java b/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/api/QwenApiHelper.java index 7830804d416..9439da99589 100644 --- a/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/api/QwenApiHelper.java +++ b/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/api/QwenApiHelper.java @@ -592,14 +592,14 @@ private static List mergeChoices(GenerationOutput outpu // there is only one. GenerationOutput.Choice lastPreviousChoice = null; - if (previous != null) { + if (!CollectionUtils.isEmpty(previous)) { lastPreviousChoice = previous.get(previous.size() - 1); if (previous.size() > 1) { choices.addAll(previous.subList(0, previous.size() - 1)); } } - if (current != null) { + if (!CollectionUtils.isEmpty(current)) { if (current.size() > 1) { throw new IllegalStateException("Currently only one choice is supported per message!"); } @@ -671,14 +671,14 @@ private static List mergeToolCalls(List previous, Li // one. ToolCallBase lastPreviousTooCall = null; - if (previous != null) { + if (!CollectionUtils.isEmpty(previous)) { lastPreviousTooCall = previous.get(previous.size() - 1); if (previous.size() > 1) { toolCalls.addAll(previous.subList(0, previous.size() - 1)); } } - if (current != null) { + if (!CollectionUtils.isEmpty(current)) { if (current.size() > 1) { throw new IllegalStateException("Currently only one tool call is supported per message!"); } From 93af4aad7e88dd5a22bb87dfd807b3b7f4ee4b79 Mon Sep 17 00:00:00 2001 From: jiangsier-xyz <126842484+jiangsier-xyz@users.noreply.github.com> Date: Wed, 23 Apr 2025 16:06:21 +0800 Subject: [PATCH 06/13] chore: adjust comments Signed-off-by: jiangsier-xyz <126842484+jiangsier-xyz@users.noreply.github.com> --- .../org/springframework/ai/qwen/api/QwenApiHelper.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/api/QwenApiHelper.java b/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/api/QwenApiHelper.java index 9439da99589..a07a42f5285 100644 --- a/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/api/QwenApiHelper.java +++ b/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/api/QwenApiHelper.java @@ -588,8 +588,8 @@ private static SearchInfo mergeSearchInfo(SearchInfo previous, SearchInfo curren private static List mergeChoices(GenerationOutput output, List previous, List current) { - List choices = new ArrayList<>(1); // in most cases, - // there is only one. + // in most cases, there is only one + List choices = new ArrayList<>(1); GenerationOutput.Choice lastPreviousChoice = null; if (!CollectionUtils.isEmpty(previous)) { @@ -667,8 +667,8 @@ private static com.alibaba.dashscope.common.Message mergeMessage(com.alibaba.das } private static List mergeToolCalls(List previous, List current) { - List toolCalls = new ArrayList<>(1); // in most cases, there is only - // one. + // in most cases, there is only one + List toolCalls = new ArrayList<>(1); ToolCallBase lastPreviousTooCall = null; if (!CollectionUtils.isEmpty(previous)) { From 12f55aa35a470285eeff6e2d619183d45dab263a Mon Sep 17 00:00:00 2001 From: jiangsier-xyz <126842484+jiangsier-xyz@users.noreply.github.com> Date: Thu, 24 Apr 2025 10:49:16 +0800 Subject: [PATCH 07/13] Support multiple choices and toolcalls in streaming mode Signed-off-by: jiangsier-xyz <126842484+jiangsier-xyz@users.noreply.github.com> --- .../ai/qwen/api/QwenApiHelper.java | 28 +++++++++++-------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/api/QwenApiHelper.java b/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/api/QwenApiHelper.java index a07a42f5285..60545f5f407 100644 --- a/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/api/QwenApiHelper.java +++ b/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/api/QwenApiHelper.java @@ -197,7 +197,7 @@ private static List> toMultiModalContents(Message message) { media.stream().map(QwenApiHelper::toMultiModalContent).forEach(contents::add); if (message instanceof ToolResponseMessage toolMessage) { - List toolResponses = toolMessage.getResponses(); + List toolResponses = toolMessage.getResponses(); if (!CollectionUtils.isEmpty(toolResponses)) { for (ToolResponseMessage.ToolResponse toolResponse : toolResponses) { contents.add(Map.of("content", toolResponse.responseData(), "tool_call_id", toolResponse.id())); @@ -600,11 +600,13 @@ private static List mergeChoices(GenerationOutput outpu } if (!CollectionUtils.isEmpty(current)) { - if (current.size() > 1) { - throw new IllegalStateException("Currently only one choice is supported per message!"); + var iterator = current.iterator(); + var firstChoice = iterator.next(); + // the first one should be merged with previous last one + choices.add(mergeChoice(output, lastPreviousChoice, firstChoice)); + while (iterator.hasNext()) { + choices.add(iterator.next()); } - var currentChoice = current.iterator().next(); - choices.add(mergeChoice(output, lastPreviousChoice, currentChoice)); } else { if (lastPreviousChoice != null) { @@ -679,18 +681,20 @@ private static List mergeToolCalls(List previous, Li } if (!CollectionUtils.isEmpty(current)) { - if (current.size() > 1) { - throw new IllegalStateException("Currently only one tool call is supported per message!"); - } - var currentToolCall = current.iterator().next(); - if (StringUtils.hasText(currentToolCall.getId())) { + var iterator = current.iterator(); + var firstToolCall = iterator.next(); + // the first one should be merged with previous last one + if (StringUtils.hasText(firstToolCall.getId())) { if (lastPreviousTooCall != null) { toolCalls.add(lastPreviousTooCall); } - toolCalls.add(currentToolCall); + toolCalls.add(firstToolCall); } else { - toolCalls.add(mergeToolCall(lastPreviousTooCall, currentToolCall)); + toolCalls.add(mergeToolCall(lastPreviousTooCall, firstToolCall)); + } + while (iterator.hasNext()) { + toolCalls.add(iterator.next()); } } else { From 10b86e17d3d80b6b26bd676048e0dc7182373e68 Mon Sep 17 00:00:00 2001 From: jiangsier-xyz <126842484+jiangsier-xyz@users.noreply.github.com> Date: Thu, 24 Apr 2025 14:13:29 +0800 Subject: [PATCH 08/13] chore: adjust comment Signed-off-by: jiangsier-xyz <126842484+jiangsier-xyz@users.noreply.github.com> --- .../main/java/org/springframework/ai/qwen/QwenChatOptions.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/QwenChatOptions.java b/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/QwenChatOptions.java index 43ba1f03a07..ae22d0ee6cb 100644 --- a/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/QwenChatOptions.java +++ b/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/QwenChatOptions.java @@ -60,8 +60,7 @@ public class QwenChatOptions implements ToolCallingChatOptions { /** * If specified, our system will make a best effort to sample deterministically, such * that repeated requests with the same seed and parameters should return the same - * result. Determinism is not guaranteed, and you should refer to the - * system_fingerprint response parameter to monitor changes in the backend. + * result. */ private Integer seed; From 6640140db6e6654a8c8f6183caddc9b82723ab22 Mon Sep 17 00:00:00 2001 From: jiangsier-xyz <126842484+jiangsier-xyz@users.noreply.github.com> Date: Thu, 24 Apr 2025 14:24:25 +0800 Subject: [PATCH 09/13] chore: adjust field name of QwenChatOptions.Builder Signed-off-by: jiangsier-xyz <126842484+jiangsier-xyz@users.noreply.github.com> --- .../java/org/springframework/ai/qwen/QwenChatOptions.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/QwenChatOptions.java b/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/QwenChatOptions.java index ae22d0ee6cb..02094285f28 100644 --- a/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/QwenChatOptions.java +++ b/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/QwenChatOptions.java @@ -188,7 +188,7 @@ private QwenChatOptions(Builder builder) { this.searchOptions = builder.searchOptions; this.translationOptions = builder.translationOptions; this.vlHighResolutionImages = builder.vlHighResolutionImages; - this.isMultimodalModel = builder.multimodalModel; + this.isMultimodalModel = builder.isMultimodalModel; this.custom = builder.custom; } @@ -446,7 +446,7 @@ public static class Builder { private Boolean vlHighResolutionImages; - private Boolean multimodalModel; + private Boolean isMultimodalModel; private Boolean supportIncrementalOutput; @@ -548,7 +548,7 @@ public Builder vlHighResolutionImages(Boolean vlHighResolutionImages) { } public Builder isMultimodalModel(Boolean isMultimodalModel) { - this.multimodalModel = isMultimodalModel; + this.isMultimodalModel = isMultimodalModel; return this; } @@ -588,7 +588,7 @@ public Builder overrideWith(QwenChatOptions fromOptions) { this.translationOptions(getOrDefault(fromOptions.getTranslationOptions(), this.translationOptions)); this.vlHighResolutionImages( getOrDefault(fromOptions.getVlHighResolutionImages(), this.vlHighResolutionImages)); - this.isMultimodalModel(getOrDefault(fromOptions.getIsMultimodalModel(), this.multimodalModel)); + this.isMultimodalModel(getOrDefault(fromOptions.getIsMultimodalModel(), this.isMultimodalModel)); this.supportIncrementalOutput( getOrDefault(fromOptions.getSupportIncrementalOutput(), this.supportIncrementalOutput)); this.custom(copyIfNotNull(getOrDefault(fromOptions.getCustom(), this.custom))); From 598f10b40ad875985588e50bbf307f469ee0bbed Mon Sep 17 00:00:00 2001 From: jiangsier-xyz <126842484+jiangsier-xyz@users.noreply.github.com> Date: Thu, 24 Apr 2025 14:26:46 +0800 Subject: [PATCH 10/13] fix: miss supportIncrementalOutput setting Signed-off-by: jiangsier-xyz <126842484+jiangsier-xyz@users.noreply.github.com> --- .../main/java/org/springframework/ai/qwen/QwenChatOptions.java | 1 + 1 file changed, 1 insertion(+) diff --git a/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/QwenChatOptions.java b/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/QwenChatOptions.java index 02094285f28..c658f6249c8 100644 --- a/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/QwenChatOptions.java +++ b/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/QwenChatOptions.java @@ -189,6 +189,7 @@ private QwenChatOptions(Builder builder) { this.translationOptions = builder.translationOptions; this.vlHighResolutionImages = builder.vlHighResolutionImages; this.isMultimodalModel = builder.isMultimodalModel; + this.supportIncrementalOutput = builder.supportIncrementalOutput; this.custom = builder.custom; } From df52f4432414fef8a00c2f64cbc172d91dca50e4 Mon Sep 17 00:00:00 2001 From: jiangsier-xyz <126842484+jiangsier-xyz@users.noreply.github.com> Date: Thu, 24 Apr 2025 14:36:34 +0800 Subject: [PATCH 11/13] chore: adjust comments Signed-off-by: jiangsier-xyz <126842484+jiangsier-xyz@users.noreply.github.com> --- .../java/org/springframework/ai/qwen/QwenChatOptions.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/QwenChatOptions.java b/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/QwenChatOptions.java index c658f6249c8..743286d81d2 100644 --- a/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/QwenChatOptions.java +++ b/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/QwenChatOptions.java @@ -673,12 +673,12 @@ public SearchOptions build() { * * @param sourceLang The full English name of the source language.For more * information, see Supported + * "https://www.alibabacloud.com/help/en/model-studio/machine-translation">Supported * Languages. You can set source_lang to "auto" and the model will automatically * determine the language of the input text. * @param targetLang The full English name of the target language.For more * information, see Supported + * "https://www.alibabacloud.com/help/en/model-studio/machine-translation">Supported * Languages. * @param terms An array of terms that needs to be set when using the * term-intervention-translation feature. From 7ecfe5233f966363e5c86d3f534a13c7e29687bc Mon Sep 17 00:00:00 2001 From: jiangsier-xyz <126842484+jiangsier-xyz@users.noreply.github.com> Date: Thu, 24 Apr 2025 14:43:22 +0800 Subject: [PATCH 12/13] chore: remove unnecessary public modifier Signed-off-by: jiangsier-xyz <126842484+jiangsier-xyz@users.noreply.github.com> --- .../java/org/springframework/ai/qwen/api/QwenApiHelper.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/api/QwenApiHelper.java b/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/api/QwenApiHelper.java index 60545f5f407..18378a76c72 100644 --- a/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/api/QwenApiHelper.java +++ b/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/api/QwenApiHelper.java @@ -72,7 +72,7 @@ static boolean isMultimodalModelName(String modelName) { return modelName.contains("-vl-") || modelName.contains("-audio-"); } - public static boolean isSupportingIncrementalOutputModelName(String modelName) { + static boolean isSupportingIncrementalOutputModelName(String modelName) { // rough judgment return !(modelName.contains("-vl-") || modelName.contains("-audio-") || modelName.contains("-mt-")); } From 6b3a2b9e5f89f80a0b33d9a508f9831b037137e3 Mon Sep 17 00:00:00 2001 From: jiangsier-xyz <126842484+jiangsier-xyz@users.noreply.github.com> Date: Fri, 25 Apr 2025 22:44:17 +0800 Subject: [PATCH 13/13] fix: javadoc building issue Signed-off-by: jiangsier-xyz <126842484+jiangsier-xyz@users.noreply.github.com> --- .../java/org/springframework/ai/qwen/QwenChatOptions.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/QwenChatOptions.java b/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/QwenChatOptions.java index 743286d81d2..6af8a04ab65 100644 --- a/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/QwenChatOptions.java +++ b/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/QwenChatOptions.java @@ -611,7 +611,7 @@ public QwenChatOptions build() { * annotation function. This function takes effect only when enable_source is true. * Default value is false. * @param citationFormat Subscript style. Only available when enable_citation is true. - * Supported styles: “[]” and “[ref_]”. Default value is “[]”. + * Supported styles: “[1]” and “[ref_1]”. Default value is “[1]”. * @param forcedSearch Whether to force search to start. * @param searchStrategy The amount of Internet information searched. Supported * values: “standard” and “pro”. Default value is “standard”. @@ -739,6 +739,8 @@ public TranslationOptions build() { } /** + * The term. + * * @param source The term in the source language. * @param target The term in the target language. */