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..541f486b624
--- /dev/null
+++ b/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/QwenChatModel.java
@@ -0,0 +1,248 @@
+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.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();
+
+ 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);
+ });
+ }
+
+ 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 {
+ 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..6af8a04ab65
--- /dev/null
+++ b/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/QwenChatOptions.java
@@ -0,0 +1,776 @@
+package org.springframework.ai.qwen;
+
+import com.alibaba.dashscope.common.ResponseFormat;
+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.
+ */
+ 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 isMultimodalModel;
+
+ /**
+ * 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.isMultimodalModel = builder.isMultimodalModel;
+ this.supportIncrementalOutput = builder.supportIncrementalOutput;
+ 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
+ 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
+ @Nullable
+ public Boolean getInternalToolExecutionEnabled() {
+ 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 getIsMultimodalModel() {
+ return isMultimodalModel;
+ }
+
+ public void setIsMultimodalModel(Boolean isMultimodalModel) {
+ this.isMultimodalModel = isMultimodalModel;
+ }
+
+ 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 isMultimodalModel;
+
+ 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.isMultimodalModel = 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.getIsMultimodalModel(), this.isMultimodalModel));
+ 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: “[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”.
+ */
+ 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);
+ }
+
+ }
+ }
+
+ /**
+ * The term.
+ *
+ * @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..553b0c617fe
--- /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).getIsMultimodalModel();
+ 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..18378a76c72
--- /dev/null
+++ b/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/api/QwenApiHelper.java
@@ -0,0 +1,988 @@
+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.qwen.QwenChatOptions;
+import org.springframework.ai.tool.ToolCallback;
+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-");
+ }
+
+ 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(ToolCallback toolCallback) {
+ FunctionDefinition functionDefinition = FunctionDefinition.builder()
+ .name(toolCallback.getToolDefinition().name())
+ .description(getOrDefault(toolCallback.getToolDefinition().description(), ""))
+ .parameters(toParameters(toolCallback))
+ .build();
+ return ToolFunction.builder().function(functionDefinition).build();
+ }
+
+ private static JsonObject toParameters(ToolCallback toolCallback) {
+ if (StringUtils.hasText(toolCallback.getToolDefinition().inputSchema())) {
+ return JsonUtils.parse(toolCallback.getToolDefinition().inputSchema());
+ }
+ 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