Add support for QianFan AI models

- Add chat, embeddin and image models
- Tests
- Docs
This commit is contained in:
GR
2024-05-22 18:08:24 +08:00
committed by Mark Pollack
parent db53166779
commit 997e01ca6b
43 changed files with 4352 additions and 2 deletions

View File

@@ -0,0 +1,5 @@
[QianFan Chat Documentation](https://docs.spring.io/spring-ai/reference/1.0-SNAPSHOT/api/chat/qianfan-chat.html)
[QianFan Embedding Documentation](https://docs.spring.io/spring-ai/reference/1.0-SNAPSHOT/api/embeddings/qianfan-embeddings.html)
[QianFan Image Documentation](https://docs.spring.io/spring-ai/reference/1.0-SNAPSHOT/api/image/qianfan-image.html)

View File

@@ -0,0 +1,59 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai</artifactId>
<version>1.0.0-SNAPSHOT</version>
<relativePath>../../pom.xml</relativePath>
</parent>
<artifactId>spring-ai-qianfan</artifactId>
<packaging>jar</packaging>
<name>Spring AI QianFan</name>
<description>Baidu QianFan support</description>
<url>https://github.com/spring-projects/spring-ai</url>
<scm>
<url>https://github.com/spring-projects/spring-ai</url>
<connection>git://github.com/spring-projects/spring-ai.git</connection>
<developerConnection>git@github.com:spring-projects/spring-ai.git</developerConnection>
</scm>
<dependencies>
<!-- production dependencies -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-core</artifactId>
<version>${project.parent.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-retry</artifactId>
<version>${project.parent.version}</version>
</dependency>
<!-- Spring Framework -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context-support</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-logging</artifactId>
</dependency>
<!-- test dependencies -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-test</artifactId>
<version>${project.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,205 @@
/*
* Copyright 2023 - 2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.qianfan;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.chat.model.StreamingChatModel;
import org.springframework.ai.chat.prompt.ChatOptions;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.model.ModelOptionsUtils;
import org.springframework.ai.qianfan.api.QianFanApi;
import org.springframework.ai.qianfan.api.QianFanApi.ChatCompletion;
import org.springframework.ai.qianfan.api.QianFanApi.ChatCompletionChunk;
import org.springframework.ai.qianfan.api.QianFanApi.ChatCompletionMessage;
import org.springframework.ai.qianfan.api.QianFanApi.ChatCompletionMessage.Role;
import org.springframework.ai.qianfan.api.QianFanApi.ChatCompletionRequest;
import org.springframework.ai.retry.RetryUtils;
import org.springframework.http.ResponseEntity;
import org.springframework.retry.support.RetryTemplate;
import org.springframework.util.Assert;
import reactor.core.publisher.Flux;
import java.util.Collections;
import java.util.List;
import java.util.Map;
/**
* {@link ChatModel} and {@link StreamingChatModel} implementation for {@literal QianFan}
* backed by {@link QianFanApi}.
*
* @author Geng Rong
* @since 1.0
* @see ChatModel
* @see StreamingChatModel
* @see QianFanApi
*/
public class QianFanChatModel implements ChatModel, StreamingChatModel {
private static final Logger logger = LoggerFactory.getLogger(QianFanChatModel.class);
/**
* The default options used for the chat completion requests.
*/
private final QianFanChatOptions defaultOptions;
/**
* The retry template used to retry the QianFan API calls.
*/
public final RetryTemplate retryTemplate;
/**
* Low-level access to the QianFan API.
*/
private final QianFanApi qianFanApi;
/**
* Creates an instance of the QianFanChatModel.
* @param qianFanApi The QianFanApi instance to be used for interacting with the
* QianFan Chat API.
* @throws IllegalArgumentException if QianFanApi is null
*/
public QianFanChatModel(QianFanApi qianFanApi) {
this(qianFanApi,
QianFanChatOptions.builder().withModel(QianFanApi.DEFAULT_CHAT_MODEL).withTemperature(0.7f).build());
}
/**
* Initializes an instance of the QianFanChatModel.
* @param qianFanApi The QianFanApi instance to be used for interacting with the
* QianFan Chat API.
* @param options The QianFanChatOptions to configure the chat client.
*/
public QianFanChatModel(QianFanApi qianFanApi, QianFanChatOptions options) {
this(qianFanApi, options, RetryUtils.DEFAULT_RETRY_TEMPLATE);
}
/**
* Initializes a new instance of the QianFanChatModel.
* @param qianFanApi The QianFanApi instance to be used for interacting with the
* QianFan Chat API.
* @param options The QianFanChatOptions to configure the chat client.
* @param retryTemplate The retry template.
*/
public QianFanChatModel(QianFanApi qianFanApi, QianFanChatOptions options, RetryTemplate retryTemplate) {
Assert.notNull(qianFanApi, "QianFanApi must not be null");
Assert.notNull(options, "Options must not be null");
Assert.notNull(retryTemplate, "RetryTemplate must not be null");
this.qianFanApi = qianFanApi;
this.defaultOptions = options;
this.retryTemplate = retryTemplate;
}
@Override
public ChatResponse call(Prompt prompt) {
ChatCompletionRequest request = createRequest(prompt, false);
return this.retryTemplate.execute(ctx -> {
ResponseEntity<ChatCompletion> completionEntity = this.doChatCompletion(request);
var chatCompletion = completionEntity.getBody();
if (chatCompletion == null) {
logger.warn("No chat completion returned for prompt: {}", prompt);
return new ChatResponse(List.of());
}
// if (chatCompletion.baseResponse() != null &&
// chatCompletion.baseResponse().statusCode() != 0) {
// throw new RuntimeException(chatCompletion.baseResponse().message());
// }
var generation = new Generation(chatCompletion.result(),
Map.of("id", chatCompletion.id(), "role", Role.ASSISTANT));
return new ChatResponse(Collections.singletonList(generation));
});
}
@Override
public Flux<ChatResponse> stream(Prompt prompt) {
var request = createRequest(prompt, true);
return retryTemplate.execute(ctx -> {
var completionChunks = this.qianFanApi.chatCompletionStream(request);
return completionChunks.map(this::toChatCompletion).map(chatCompletion -> {
String id = chatCompletion.id();
var generation = new Generation(chatCompletion.result(), Map.of("id", id, "role", Role.ASSISTANT));
return new ChatResponse(Collections.singletonList(generation));
});
});
}
/**
* Convert the ChatCompletionChunk into a ChatCompletion.
* @param chunk the ChatCompletionChunk to convert
* @return the ChatCompletion
*/
private ChatCompletion toChatCompletion(ChatCompletionChunk chunk) {
return new ChatCompletion(chunk.id(), chunk.object(), chunk.created(), chunk.result(), chunk.usage());
}
/**
* Accessible for testing.
*/
public ChatCompletionRequest createRequest(Prompt prompt, boolean stream) {
var chatCompletionMessages = prompt.getInstructions()
.stream()
.map(m -> new ChatCompletionMessage(m.getContent(),
ChatCompletionMessage.Role.valueOf(m.getMessageType().name())))
.toList();
var systemMessageList = chatCompletionMessages.stream().filter(msg -> msg.role() == Role.SYSTEM).toList();
if (systemMessageList.size() > 1) {
throw new IllegalArgumentException("Only one system message is allowed in the prompt");
}
var systemMessage = systemMessageList.isEmpty() ? null : systemMessageList.get(0).content();
var request = new ChatCompletionRequest(chatCompletionMessages, systemMessage, stream);
if (this.defaultOptions != null) {
request = ModelOptionsUtils.merge(this.defaultOptions, request, ChatCompletionRequest.class);
}
if (prompt.getOptions() != null) {
if (prompt.getOptions() != null) {
var updatedRuntimeOptions = ModelOptionsUtils.copyToTarget(prompt.getOptions(), ChatOptions.class,
QianFanChatOptions.class);
request = ModelOptionsUtils.merge(updatedRuntimeOptions, request, ChatCompletionRequest.class);
}
else {
throw new IllegalArgumentException("Prompt options are not of type ChatOptions: "
+ prompt.getOptions().getClass().getSimpleName());
}
}
return request;
}
@Override
public ChatOptions getDefaultOptions() {
return QianFanChatOptions.fromOptions(this.defaultOptions);
}
private ResponseEntity<ChatCompletion> doChatCompletion(ChatCompletionRequest request) {
return this.qianFanApi.chatCompletionEntity(request);
}
}

View File

@@ -0,0 +1,306 @@
/*
* Copyright 2023 - 2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.qianfan;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonInclude.Include;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.springframework.ai.chat.prompt.ChatOptions;
import org.springframework.ai.qianfan.api.QianFanApi;
import org.springframework.boot.context.properties.NestedConfigurationProperty;
import java.util.List;
/**
* QianFanChatOptions represents the options for performing chat completion using the
* QianFan API. It provides methods to set and retrieve various options like model,
* frequency penalty, max tokens, etc.
*
* @author Geng Rong
* @since 1.0
* @see ChatOptions
*/
@JsonInclude(Include.NON_NULL)
public class QianFanChatOptions implements ChatOptions {
// @formatter:off
/**
* ID of the model to use.
*/
private @JsonProperty("model") 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 @JsonProperty("frequency_penalty") Float 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 @JsonProperty("max_tokens") 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 @JsonProperty("presence_penalty") Float 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 @JsonProperty("response_format") QianFanApi.ChatCompletionRequest.ResponseFormat responseFormat;
/**
* Up to 4 sequences where the API will stop generating further tokens.
*/
@NestedConfigurationProperty
private @JsonProperty("stop") List<String> stop;
/**
* 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 @JsonProperty("temperature") Float 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 @JsonProperty("top_p") Float topP;
// @formatter:on
public static Builder builder() {
return new Builder();
}
public static class Builder {
protected QianFanChatOptions options;
public Builder() {
this.options = new QianFanChatOptions();
}
public Builder(QianFanChatOptions options) {
this.options = options;
}
public Builder withModel(String model) {
this.options.model = model;
return this;
}
public Builder withFrequencyPenalty(Float frequencyPenalty) {
this.options.frequencyPenalty = frequencyPenalty;
return this;
}
public Builder withMaxTokens(Integer maxTokens) {
this.options.maxTokens = maxTokens;
return this;
}
public Builder withPresencePenalty(Float presencePenalty) {
this.options.presencePenalty = presencePenalty;
return this;
}
public Builder withResponseFormat(QianFanApi.ChatCompletionRequest.ResponseFormat responseFormat) {
this.options.responseFormat = responseFormat;
return this;
}
public Builder withStop(List<String> stop) {
this.options.stop = stop;
return this;
}
public Builder withTemperature(Float temperature) {
this.options.temperature = temperature;
return this;
}
public Builder withTopP(Float topP) {
this.options.topP = topP;
return this;
}
public QianFanChatOptions build() {
return this.options;
}
}
public String getModel() {
return this.model;
}
public void setModel(String model) {
this.model = model;
}
public Float getFrequencyPenalty() {
return this.frequencyPenalty;
}
public void setFrequencyPenalty(Float frequencyPenalty) {
this.frequencyPenalty = frequencyPenalty;
}
public Integer getMaxTokens() {
return this.maxTokens;
}
public void setMaxTokens(Integer maxTokens) {
this.maxTokens = maxTokens;
}
public Float getPresencePenalty() {
return this.presencePenalty;
}
public void setPresencePenalty(Float presencePenalty) {
this.presencePenalty = presencePenalty;
}
public QianFanApi.ChatCompletionRequest.ResponseFormat getResponseFormat() {
return this.responseFormat;
}
public void setResponseFormat(QianFanApi.ChatCompletionRequest.ResponseFormat responseFormat) {
this.responseFormat = responseFormat;
}
public List<String> getStop() {
return this.stop;
}
public void setStop(List<String> stop) {
this.stop = stop;
}
@Override
public Float getTemperature() {
return this.temperature;
}
public void setTemperature(Float temperature) {
this.temperature = temperature;
}
@Override
public Float getTopP() {
return this.topP;
}
public void setTopP(Float topP) {
this.topP = topP;
}
@Override
@JsonIgnore
public Integer getTopK() {
throw new UnsupportedOperationException("Unimplemented method 'getTopK'");
}
@Override
public int hashCode() {
final int prime = 31;
int result = 1;
result = prime * result + ((model == null) ? 0 : model.hashCode());
result = prime * result + ((frequencyPenalty == null) ? 0 : frequencyPenalty.hashCode());
result = prime * result + ((maxTokens == null) ? 0 : maxTokens.hashCode());
result = prime * result + ((presencePenalty == null) ? 0 : presencePenalty.hashCode());
result = prime * result + ((responseFormat == null) ? 0 : responseFormat.hashCode());
result = prime * result + ((stop == null) ? 0 : stop.hashCode());
result = prime * result + ((temperature == null) ? 0 : temperature.hashCode());
result = prime * result + ((topP == null) ? 0 : topP.hashCode());
return result;
}
@Override
public boolean equals(Object obj) {
if (this == obj)
return true;
if (obj == null)
return false;
if (getClass() != obj.getClass())
return false;
QianFanChatOptions other = (QianFanChatOptions) obj;
if (this.model == null) {
if (other.model != null)
return false;
}
else if (!model.equals(other.model))
return false;
if (this.frequencyPenalty == null) {
if (other.frequencyPenalty != null)
return false;
}
else if (!this.frequencyPenalty.equals(other.frequencyPenalty))
return false;
if (this.maxTokens == null) {
if (other.maxTokens != null)
return false;
}
else if (!this.maxTokens.equals(other.maxTokens))
return false;
if (this.presencePenalty == null) {
if (other.presencePenalty != null)
return false;
}
else if (!this.presencePenalty.equals(other.presencePenalty))
return false;
if (this.responseFormat == null) {
if (other.responseFormat != null)
return false;
}
else if (!this.responseFormat.equals(other.responseFormat))
return false;
if (this.stop == null) {
if (other.stop != null)
return false;
}
else if (!stop.equals(other.stop))
return false;
if (this.temperature == null) {
if (other.temperature != null)
return false;
}
else if (!this.temperature.equals(other.temperature))
return false;
if (this.topP == null) {
if (other.topP != null)
return false;
}
else if (!topP.equals(other.topP))
return false;
return true;
}
public static QianFanChatOptions fromOptions(QianFanChatOptions fromOptions) {
return QianFanChatOptions.builder()
.withModel(fromOptions.getModel())
.withFrequencyPenalty(fromOptions.getFrequencyPenalty())
.withMaxTokens(fromOptions.getMaxTokens())
.withPresencePenalty(fromOptions.getPresencePenalty())
.withResponseFormat(fromOptions.getResponseFormat())
.withStop(fromOptions.getStop())
.withTemperature(fromOptions.getTemperature())
.withTopP(fromOptions.getTopP())
.build();
}
}

View File

@@ -0,0 +1,157 @@
/*
* Copyright 2023 - 2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.qianfan;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.document.Document;
import org.springframework.ai.document.MetadataMode;
import org.springframework.ai.embedding.AbstractEmbeddingModel;
import org.springframework.ai.embedding.Embedding;
import org.springframework.ai.embedding.EmbeddingOptions;
import org.springframework.ai.embedding.EmbeddingRequest;
import org.springframework.ai.embedding.EmbeddingResponse;
import org.springframework.ai.embedding.EmbeddingResponseMetadata;
import org.springframework.ai.model.ModelOptionsUtils;
import org.springframework.ai.qianfan.api.QianFanApi;
import org.springframework.ai.qianfan.api.QianFanApi.EmbeddingList;
import org.springframework.ai.retry.RetryUtils;
import org.springframework.retry.support.RetryTemplate;
import org.springframework.util.Assert;
import java.util.List;
/**
* QianFan Embedding Client implementation.
*
* @author Geng Rong
* @since 1.0
*/
public class QianFanEmbeddingModel extends AbstractEmbeddingModel {
private static final Logger logger = LoggerFactory.getLogger(QianFanEmbeddingModel.class);
private final QianFanEmbeddingOptions defaultOptions;
private final RetryTemplate retryTemplate;
private final QianFanApi qianFanApi;
private final MetadataMode metadataMode;
/**
* Constructor for the QianFanEmbeddingModel class.
* @param qianFanApi The QianFanApi instance to use for making API requests.
*/
public QianFanEmbeddingModel(QianFanApi qianFanApi) {
this(qianFanApi, MetadataMode.EMBED);
}
/**
* Initializes a new instance of the QianFanEmbeddingModel class.
* @param qianFanApi The QianFanApi instance to use for making API requests.
* @param metadataMode The mode for generating metadata.
*/
public QianFanEmbeddingModel(QianFanApi qianFanApi, MetadataMode metadataMode) {
this(qianFanApi, metadataMode,
QianFanEmbeddingOptions.builder().withModel(QianFanApi.DEFAULT_EMBEDDING_MODEL).build(),
RetryUtils.DEFAULT_RETRY_TEMPLATE);
}
/**
* Initializes a new instance of the QianFanEmbeddingModel class.
* @param qianFanApi The QianFanApi instance to use for making API requests.
* @param metadataMode The mode for generating metadata.
* @param qianFanEmbeddingOptions The options for QianFan embedding.
*/
public QianFanEmbeddingModel(QianFanApi qianFanApi, MetadataMode metadataMode,
QianFanEmbeddingOptions qianFanEmbeddingOptions) {
this(qianFanApi, metadataMode, qianFanEmbeddingOptions, RetryUtils.DEFAULT_RETRY_TEMPLATE);
}
/**
* Initializes a new instance of the QianFanEmbeddingModel class.
* @param qianFanApi - The QianFanApi instance to use for making API requests.
* @param metadataMode - The mode for generating metadata.
* @param options - The options for QianFan embedding.
* @param retryTemplate - The RetryTemplate for retrying failed API requests.
*/
public QianFanEmbeddingModel(QianFanApi qianFanApi, MetadataMode metadataMode, QianFanEmbeddingOptions options,
RetryTemplate retryTemplate) {
Assert.notNull(qianFanApi, "QianFanApi must not be null");
Assert.notNull(metadataMode, "metadataMode must not be null");
Assert.notNull(options, "options must not be null");
Assert.notNull(retryTemplate, "retryTemplate must not be null");
this.qianFanApi = qianFanApi;
this.metadataMode = metadataMode;
this.defaultOptions = options;
this.retryTemplate = retryTemplate;
}
@Override
public List<Double> embed(Document document) {
Assert.notNull(document, "Document must not be null");
return this.embed(document.getFormattedContent(this.metadataMode));
}
@Override
public EmbeddingResponse call(EmbeddingRequest request) {
return this.retryTemplate.execute(ctx -> {
QianFanApi.EmbeddingRequest apiRequest = (this.defaultOptions != null)
? new QianFanApi.EmbeddingRequest(request.getInstructions(), this.defaultOptions.getModel(),
this.defaultOptions.getUser())
: new QianFanApi.EmbeddingRequest(request.getInstructions());
if (request.getOptions() != null && !EmbeddingOptions.EMPTY.equals(request.getOptions())) {
apiRequest = ModelOptionsUtils.merge(request.getOptions(), apiRequest,
QianFanApi.EmbeddingRequest.class);
}
EmbeddingList apiEmbeddingResponse = this.qianFanApi.embeddings(apiRequest).getBody();
if (apiEmbeddingResponse == null) {
logger.warn("No embeddings returned for request: {}", request);
return new EmbeddingResponse(List.of());
}
if (apiEmbeddingResponse.errorNsg() != null) {
logger.error("Error message returned for request: {}", apiEmbeddingResponse.errorNsg());
throw new RuntimeException("Embedding failed: error code:" + apiEmbeddingResponse.errorCode()
+ ", message:" + apiEmbeddingResponse.errorNsg());
}
var metadata = generateResponseMetadata(apiEmbeddingResponse.model(), apiEmbeddingResponse.usage());
List<Embedding> embeddings = apiEmbeddingResponse.data()
.stream()
.map(e -> new Embedding(e.embedding(), e.index()))
.toList();
return new EmbeddingResponse(embeddings, metadata);
});
}
private EmbeddingResponseMetadata generateResponseMetadata(String model, QianFanApi.Usage usage) {
EmbeddingResponseMetadata metadata = new EmbeddingResponseMetadata();
metadata.put("model", model);
metadata.put("prompt-tokens", usage.promptTokens());
metadata.put("total-tokens", usage.totalTokens());
return metadata;
}
}

View File

@@ -0,0 +1,89 @@
/*
* Copyright 2023 - 2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.qianfan;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonInclude.Include;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.springframework.ai.embedding.EmbeddingOptions;
/**
* This class represents the options for QianFan embedding.
*
* @author Geng Rong
* @since 1.0
*/
@JsonInclude(Include.NON_NULL)
public class QianFanEmbeddingOptions implements EmbeddingOptions {
// @formatter:off
/**
* ID of the model to use.
*/
private @JsonProperty("model") String model;
/**
* A unique identifier representing your end-user, which can help QianFan to
* monitor and detect abuse.
*/
private @JsonProperty("user_id") String user;
// @formatter:on
public static Builder builder() {
return new Builder();
}
public static class Builder {
protected QianFanEmbeddingOptions options;
public Builder() {
this.options = new QianFanEmbeddingOptions();
}
public Builder withModel(String model) {
this.options.setModel(model);
return this;
}
public Builder withUser(String user) {
this.options.setUser(user);
return this;
}
public QianFanEmbeddingOptions build() {
return this.options;
}
}
public String getModel() {
return this.model;
}
public void setModel(String model) {
this.model = model;
}
public String getUser() {
return user;
}
public void setUser(String user) {
this.user = user;
}
}

View File

@@ -0,0 +1,164 @@
/*
* Copyright 2023 - 2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.qianfan;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.image.Image;
import org.springframework.ai.image.ImageGeneration;
import org.springframework.ai.image.ImageModel;
import org.springframework.ai.image.ImageOptions;
import org.springframework.ai.image.ImagePrompt;
import org.springframework.ai.image.ImageResponse;
import org.springframework.ai.model.ModelOptionsUtils;
import org.springframework.ai.qianfan.api.QianFanImageApi;
import org.springframework.ai.retry.RetryUtils;
import org.springframework.http.ResponseEntity;
import org.springframework.retry.support.RetryTemplate;
import org.springframework.util.Assert;
import java.util.List;
/**
* QianFanImageModel is a class that implements the ImageModel interface. It provides a
* client for calling the QianFan image generation API.
*
* @author Geng Rong
* @since 1.0
*/
public class QianFanImageModel implements ImageModel {
private final static Logger logger = LoggerFactory.getLogger(QianFanImageModel.class);
/**
* The default options used for the image completion requests.
*/
private final QianFanImageOptions defaultOptions;
/**
* The retry template used to retry the QianFan Image API calls.
*/
private final RetryTemplate retryTemplate;
/**
* Low-level access to the QianFan Image API.
*/
private final QianFanImageApi qianFanImageApi;
/**
* Creates an instance of the QianFanImageModel.
* @param qianFanImageApi The QianFanImageApi instance to be used for interacting with
* the QianFan Image API.
* @throws IllegalArgumentException if qianFanImageApi is null
*/
public QianFanImageModel(QianFanImageApi qianFanImageApi) {
this(qianFanImageApi, QianFanImageOptions.builder().build(), RetryUtils.DEFAULT_RETRY_TEMPLATE);
}
/**
* Initializes a new instance of the QianFanImageModel.
* @param qianFanImageApi The QianFanImageApi instance to be used for interacting with
* the QianFan Image API.
* @param options The QianFanImageOptions to configure the image model.
* @param retryTemplate The retry template.
*/
public QianFanImageModel(QianFanImageApi qianFanImageApi, QianFanImageOptions options,
RetryTemplate retryTemplate) {
Assert.notNull(qianFanImageApi, "QianFanImageApi must not be null");
Assert.notNull(options, "options must not be null");
Assert.notNull(retryTemplate, "retryTemplate must not be null");
this.qianFanImageApi = qianFanImageApi;
this.defaultOptions = options;
this.retryTemplate = retryTemplate;
}
@Override
public ImageResponse call(ImagePrompt imagePrompt) {
return this.retryTemplate.execute(ctx -> {
String instructions = imagePrompt.getInstructions().get(0).getText();
QianFanImageApi.QianFanImageRequest imageRequest = new QianFanImageApi.QianFanImageRequest(instructions,
QianFanImageApi.DEFAULT_IMAGE_MODEL);
if (this.defaultOptions != null) {
imageRequest = ModelOptionsUtils.merge(this.defaultOptions, imageRequest,
QianFanImageApi.QianFanImageRequest.class);
}
if (imagePrompt.getOptions() != null) {
imageRequest = ModelOptionsUtils.merge(toQianFanImageOptions(imagePrompt.getOptions()), imageRequest,
QianFanImageApi.QianFanImageRequest.class);
}
// Make the request
ResponseEntity<QianFanImageApi.QianFanImageResponse> imageResponseEntity = this.qianFanImageApi
.createImage(imageRequest);
// Convert to org.springframework.ai.model derived ImageResponse data type
return convertResponse(imageResponseEntity, imageRequest);
});
}
private ImageResponse convertResponse(ResponseEntity<QianFanImageApi.QianFanImageResponse> imageResponseEntity,
QianFanImageApi.QianFanImageRequest qianFanImageRequest) {
QianFanImageApi.QianFanImageResponse imageApiResponse = imageResponseEntity.getBody();
if (imageApiResponse == null) {
logger.warn("No image response returned for request: {}", qianFanImageRequest);
return new ImageResponse(List.of());
}
List<ImageGeneration> imageGenerationList = imageApiResponse.data()
.stream()
.map(entry -> new ImageGeneration(new Image(null, entry.b64Image())))
.toList();
return new ImageResponse(imageGenerationList);
}
/**
* Convert the {@link ImageOptions} into {@link QianFanImageOptions}.
* @param runtimeImageOptions the image options to use.
* @return the converted {@link QianFanImageOptions}.
*/
private QianFanImageOptions toQianFanImageOptions(ImageOptions runtimeImageOptions) {
QianFanImageOptions.Builder qianFanImageOptionsBuilder = QianFanImageOptions.builder();
if (runtimeImageOptions != null) {
if (runtimeImageOptions.getN() != null) {
qianFanImageOptionsBuilder.withN(runtimeImageOptions.getN());
}
if (runtimeImageOptions.getModel() != null) {
qianFanImageOptionsBuilder.withModel(runtimeImageOptions.getModel());
}
if (runtimeImageOptions.getWidth() != null) {
qianFanImageOptionsBuilder.withWidth(runtimeImageOptions.getWidth());
}
if (runtimeImageOptions.getHeight() != null) {
qianFanImageOptionsBuilder.withHeight(runtimeImageOptions.getHeight());
}
if (runtimeImageOptions instanceof QianFanImageOptions runtimeQianFanImageOptions) {
if (runtimeQianFanImageOptions.getStyle() != null) {
qianFanImageOptionsBuilder.withStyle(runtimeQianFanImageOptions.getStyle());
}
if (runtimeQianFanImageOptions.getUser() != null) {
qianFanImageOptionsBuilder.withUser(runtimeQianFanImageOptions.getUser());
}
}
}
return qianFanImageOptionsBuilder.build();
}
}

View File

@@ -0,0 +1,227 @@
/*
* Copyright 2023 - 2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.qianfan;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.springframework.ai.image.ImageOptions;
import java.util.Objects;
/**
* QianFan Image API options. QianFanImageOptions.java
*
* @author Geng Rong
* @since 1.0
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public class QianFanImageOptions implements ImageOptions {
/**
* The number of images to generate. Must be between 1 and 4.
*/
@JsonProperty("n")
private Integer n;
/**
* The model to use for image generation.
*/
@JsonProperty("model")
private String model;
/**
* The width of the generated images. Must be one of 576, 768, 1024, 1152, 1536 or
* 2048 * for sd_xl.
*/
@JsonProperty("size_width")
private Integer width;
/**
* The height of the generated images. Must be one of 576, 768, 1024, 1152, 1536 or
* 2048 for sd_xl.
*/
@JsonProperty("size_height")
private Integer height;
/**
* The size of the generated images. The default image dimensions are 1024x1024, with
* the following ranges applicable: Suitable for avatars: ["768x768", "1024x1024",
* "1536x1536", "2048x2048"] Suitable for article illustrations: ["1024x768",
* "2048x1536"] Suitable for posters and flyers: ["768x1024", "1536x2048"] Suitable
* for computer wallpapers: ["1024x576", "2048x1152"] Suitable for posters and flyers:
* ["576x1024", "1152x2048"]
*/
@JsonProperty("size")
private String size;
/**
* The style of the generated images. The default style is Base. Must be one of:
* [Base, 3D Model, Abstract, Analog Film, Anime, Cinematic, Comic Book, Craft Clay,
* Digital Art, Enhance, Fantasy Art, Isometric, Line Art, Lowpoly, Neonpunk, Origami,
* Photographic, Pixel Art, Texture]
*/
@JsonProperty("style")
private String style;
/**
* A unique identifier representing your end-user, which can help QianFan to monitor
* and detect abuse.
*/
@JsonProperty("user_id")
private String user;
public static Builder builder() {
return new Builder();
}
public static class Builder {
private final QianFanImageOptions options;
private Builder() {
this.options = new QianFanImageOptions();
}
public Builder withN(Integer n) {
options.setN(n);
return this;
}
public Builder withModel(String model) {
options.setModel(model);
return this;
}
public Builder withWidth(Integer width) {
options.setWidth(width);
return this;
}
public Builder withHeight(Integer height) {
options.setHeight(height);
return this;
}
public Builder withStyle(String style) {
options.setStyle(style);
return this;
}
public Builder withUser(String user) {
options.setUser(user);
return this;
}
public QianFanImageOptions build() {
return options;
}
}
@Override
public Integer getN() {
return this.n;
}
public void setN(Integer n) {
this.n = n;
}
@Override
public String getModel() {
return this.model;
}
public void setModel(String model) {
this.model = model;
}
@Override
public Integer getWidth() {
return this.width;
}
public void setWidth(Integer width) {
this.width = width;
this.size = this.width + "x" + this.height;
}
@Override
public Integer getHeight() {
return this.height;
}
@Override
public String getResponseFormat() {
return null;
}
public void setHeight(Integer height) {
this.height = height;
this.size = this.width + "x" + this.height;
}
public String getStyle() {
return this.style;
}
public void setStyle(String style) {
this.style = style;
}
public String getUser() {
return this.user;
}
public void setUser(String user) {
this.user = user;
}
public void setSize(String size) {
this.size = size;
}
public String getSize() {
if (this.size != null) {
return this.size;
}
return (this.width != null && this.height != null) ? this.width + "x" + this.height : null;
}
@Override
public boolean equals(Object o) {
if (this == o)
return true;
if (!(o instanceof QianFanImageOptions that))
return false;
return Objects.equals(n, that.n) && Objects.equals(model, that.model) && Objects.equals(width, that.width)
&& Objects.equals(height, that.height) && Objects.equals(size, that.size)
&& Objects.equals(style, that.style) && Objects.equals(user, that.user);
}
@Override
public int hashCode() {
return Objects.hash(n, model, width, height, size, style, user);
}
@Override
public String toString() {
return "QianFanImageOptions{" + "n=" + n + ", model='" + model + '\'' + ", width=" + width + ", height="
+ height + ", size='" + size + '\'' + ", style='" + style + '\'' + ", user='" + user + '\'' + '}';
}
}

View File

@@ -0,0 +1,45 @@
/*
* Copyright 2023 - 2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.qianfan.aot;
import org.springframework.ai.qianfan.api.QianFanApi;
import org.springframework.ai.qianfan.api.QianFanImageApi;
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;
import static org.springframework.ai.aot.AiRuntimeHints.findJsonAnnotatedClassesInPackage;
/**
* The QianFanRuntimeHints class is responsible for registering runtime hints for QianFan
* API classes.
*
* @author Geng Rong
*/
public class QianFanRuntimeHints implements RuntimeHintsRegistrar {
@Override
public void registerHints(@NonNull RuntimeHints hints, @Nullable ClassLoader classLoader) {
var mcs = MemberCategory.values();
for (var tr : findJsonAnnotatedClassesInPackage(QianFanApi.class))
hints.reflection().registerType(tr, mcs);
for (var tr : findJsonAnnotatedClassesInPackage(QianFanImageApi.class))
hints.reflection().registerType(tr, mcs);
}
}

View File

@@ -0,0 +1,38 @@
/*
* Copyright 2023 - 2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.qianfan.api;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import java.util.function.Consumer;
/**
* The ApiUtils class provides utility methods for working with API requests and
* responses.
*
* @author Geng Rong
* @since 1.0
*/
public class ApiUtils {
public static final String DEFAULT_BASE_URL = "https://aip.baidubce.com/rpc/2.0/ai_custom";
public static Consumer<HttpHeaders> getJsonContentHeaders() {
return headers -> headers.setContentType(MediaType.APPLICATION_JSON);
}
}

View File

@@ -0,0 +1,551 @@
/*
* Copyright 2023 - 2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.qianfan.api;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonInclude.Include;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.springframework.ai.qianfan.api.auth.AuthApi;
import org.springframework.ai.retry.RetryUtils;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.ResponseEntity;
import org.springframework.util.Assert;
import org.springframework.util.CollectionUtils;
import org.springframework.web.client.ResponseErrorHandler;
import org.springframework.web.client.RestClient;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.util.List;
import java.util.function.Predicate;
// @formatter:off
/**
* Single class implementation of the QianFan Chat Completion API and Embedding API.
* <a href="https://cloud.baidu.com/doc/WENXINWORKSHOP/index.html">QianFan Docs</a>
*
* @author Geng Rong
* @since 1.0
*/
public class QianFanApi extends AuthApi {
public static final String DEFAULT_CHAT_MODEL = ChatModel.ERNIE_Speed_8K.getValue();
public static final String DEFAULT_EMBEDDING_MODEL = EmbeddingModel.BGE_LARGE_ZH.getValue();
private static final Predicate<ChatCompletionChunk> SSE_DONE_PREDICATE = ChatCompletionChunk::end;
private final RestClient restClient;
private final WebClient webClient;
/**
* Create a new chat completion api with default base URL.
*
* @param apiKey QianFan api key.
* @param secretKey QianFan secret key.
*/
public QianFanApi(String apiKey, String secretKey) {
this(ApiUtils.DEFAULT_BASE_URL, apiKey, secretKey);
}
/**
* Create a new chat completion api.
*
* @param baseUrl api base URL.
* @param apiKey QianFan api key.
* @param secretKey QianFan secret key.
*/
public QianFanApi(String baseUrl, String apiKey, String secretKey) {
this(baseUrl, apiKey, secretKey, RestClient.builder());
}
/**
* Create a new chat completion api.
*
* @param baseUrl api base URL.
* @param apiKey QianFan api key.
* @param secretKey QianFan secret key.
* @param restClientBuilder RestClient builder.
*/
public QianFanApi(String baseUrl, String apiKey, String secretKey, RestClient.Builder restClientBuilder) {
this(baseUrl, apiKey, secretKey, restClientBuilder, RetryUtils.DEFAULT_RESPONSE_ERROR_HANDLER);
}
/**
* Create a new chat completion api.
*
* @param baseUrl api base URL.
* @param apiKey QianFan api key.
* @param secretKey QianFan secret key.
* @param restClientBuilder RestClient builder.
* @param responseErrorHandler Response error handler.
*/
public QianFanApi(String baseUrl, String apiKey, String secretKey, RestClient.Builder restClientBuilder, ResponseErrorHandler responseErrorHandler) {
this(baseUrl, apiKey, secretKey, restClientBuilder, WebClient.builder(), responseErrorHandler);
}
/**
* Create a new chat completion api.
*
* @param baseUrl api base URL.
* @param apiKey QianFan api key.
* @param secretKey QianFan secret key.
* @param restClientBuilder RestClient builder.
* @param webClientBuilder WebClient builder.
* @param responseErrorHandler Response error handler.
*/
public QianFanApi(String baseUrl, String apiKey, String secretKey, RestClient.Builder restClientBuilder,
WebClient.Builder webClientBuilder,ResponseErrorHandler responseErrorHandler) {
super(apiKey, secretKey);
this.restClient = restClientBuilder
.baseUrl(baseUrl)
.defaultHeaders(ApiUtils.getJsonContentHeaders())
.defaultStatusHandler(responseErrorHandler)
.build();
this.webClient = webClientBuilder
.baseUrl(baseUrl)
.defaultHeaders(ApiUtils.getJsonContentHeaders())
.build();
}
/**
* QianFan Chat Completion Models:
* <a href="https://cloud.baidu.com/doc/WENXINWORKSHOP/s/Nlks5zkzu#%E5%AF%B9%E8%AF%9Dchat">QianFan Model</a>.
*/
public enum ChatModel {
ERNIE_4_0_8K("completions_pro"),
ERNIE_4_0_8K_Preview("ernie-4.0-8k-preview"),
ERNIE_4_0_8K_Preview_0518("completions_adv_pro"),
ERNIE_4_0_8K_0329("ernie-4.0-8k-0329"),
ERNIE_4_0_8K_0104("ernie-4.0-8k-0104"),
ERNIE_3_5_8K("completions"),
ERNIE_3_5_128K("ernie-3.5-128k"),
ERNIE_3_5_8K_Preview("ernie-3.5-8k-preview"),
ERNIE_3_5_8K_0205("ernie-3.5-8k-0205"),
ERNIE_3_5_8K_0329("ernie-3.5-8k-0329"),
ERNIE_3_5_8K_1222("ernie-3.5-8k-1222"),
ERNIE_3_5_4K_0205("ernie-3.5-4k-0205"),
ERNIE_Lite_8K_0922("eb-instant"),
ERNIE_Lite_8K_0308("ernie-lite-8k"),
ERNIE_Speed_8K("ernie_speed"),
ERNIE_Speed_128K("ernie-speed-128k"),
ERNIE_Tiny_8K("ernie-tiny-8k"),
ERNIE_FUNC_8K("ernie-func-8k");
public final String value;
ChatModel(String value) {
this.value = value;
}
public String getValue() {
return value;
}
}
/**
* Creates a model response for the given chat conversation.
*
* @param messages A list of messages comprising the conversation so far.
* @param model ID of the model to use.
* @param frequencyPenalty 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.
* @param maxTokens 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.
* appear in the text so far, increasing the model's likelihood to talk about new topics.
* @param responseFormat 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.
* @param stop Up to 4 sequences where the API will stop generating further tokens.
* @param stream If set, partial message deltas will be sent.Tokens will be sent as data-only server-sent events as
* they become available, with the stream terminated by a data: [DONE] message.
* @param temperature 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.
* @param topP 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.
*/
@JsonInclude(Include.NON_NULL)
public record ChatCompletionRequest (
@JsonProperty("messages") List<ChatCompletionMessage> messages,
@JsonProperty("system") String system,
@JsonProperty("model") String model,
@JsonProperty("frequency_penalty") Float frequencyPenalty,
@JsonProperty("max_output_tokens") Integer maxTokens,
@JsonProperty("presence_penalty") Float presencePenalty,
@JsonProperty("response_format") ResponseFormat responseFormat,
@JsonProperty("stop") List<String> stop,
@JsonProperty("stream") Boolean stream,
@JsonProperty("temperature") Float temperature,
@JsonProperty("top_p") Float topP) {
/**
* Shortcut constructor for a chat completion request with the given messages and model.
*
* @param messages A list of messages comprising the conversation so far.
* @param model ID of the model to use.
* @param temperature What sampling temperature to use, between 0 and 1.
*/
public ChatCompletionRequest(List<ChatCompletionMessage> messages, String system, String model, Float temperature) {
this(messages, system, model, null,null,
null, null, null, false, temperature, null);
}
/**
* Shortcut constructor for a chat completion request with the given messages, model and control for streaming.
*
* @param messages A list of messages comprising the conversation so far.
* @param model ID of the model to use.
* @param temperature What sampling temperature to use, between 0 and 1.
* @param stream If set, partial message deltas will be sent.Tokens will be sent as data-only server-sent events
* as they become available, with the stream terminated by a data: [DONE] message.
*/
public ChatCompletionRequest(List<ChatCompletionMessage> messages, String system, String model, Float temperature, boolean stream) {
this(messages, system, model, null,null,
null, null, null, stream, temperature, null);
}
/**
* Shortcut constructor for a chat completion request with the given messages, model, tools and tool choice.
* Streaming is set to false, temperature to 0.8 and all other parameters are null.
*
* @param messages A list of messages comprising the conversation so far.
* @param stream If set, partial message deltas will be sent.Tokens will be sent as data-only server-sent events
* as they become available, with the stream terminated by a data: [DONE] message.
*/
public ChatCompletionRequest(List<ChatCompletionMessage> messages, String system, Boolean stream) {
this(messages, system, DEFAULT_CHAT_MODEL, null,null,
null, null, null, stream, 0.8F, null);
}
/**
* An object specifying the format that the model must output.
* @param type Must be one of 'text' or 'json_object'.
*/
@JsonInclude(Include.NON_NULL)
public record ResponseFormat(
@JsonProperty("type") String type) {
}
}
/**
* Message comprising the conversation.
*
* @param rawContent The contents of the message. Can be a {@link String}.
* The response message content is always a {@link String}.
* @param role The role of the messages author. Could be one of the {@link Role} types.
*/
@JsonInclude(Include.NON_NULL)
public record ChatCompletionMessage(
@JsonProperty("content") Object rawContent,
@JsonProperty("role") Role role) {
/**
* Get message content as String.
*/
public String content() {
if (this.rawContent == null) {
return null;
}
if (this.rawContent instanceof String text) {
return text;
}
throw new IllegalStateException("The content is not a string!");
}
/**
* The role of the author of this message.
*/
public enum Role {
/**
* System message.
*/
@JsonProperty("system") SYSTEM,
/**
* User message.
*/
@JsonProperty("user") USER,
/**
* Assistant message.
*/
@JsonProperty("assistant") ASSISTANT
}
}
/**
* Represents a chat completion response returned by model, based on the provided input.
*
* @param id A unique identifier for the chat completion.
* @param result Result of chat completion message.
* @param created The Unix timestamp (in seconds) of when the chat completion was created.
* used in conjunction with the seed request parameter to understand when backend changes have been made that might
* impact determinism.
* @param object The object type, which is always chat.completion.
* @param usage Usage statistics for the completion request.
*/
@JsonInclude(Include.NON_NULL)
public record ChatCompletion(
@JsonProperty("id") String id,
@JsonProperty("object") String object,
@JsonProperty("created") Long created,
@JsonProperty("result") String result,
@JsonProperty("usage") Usage usage) {
}
/**
* Usage statistics for the completion request.
*
* @param promptTokens Number of tokens in the prompt.
* @param totalTokens Total number of tokens used in the request (prompt + completion).
*/
@JsonInclude(Include.NON_NULL)
public record Usage(
@JsonProperty("prompt_tokens") Integer promptTokens,
@JsonProperty("total_tokens") Integer totalTokens) {
}
/**
* Represents a streamed chunk of a chat completion response returned by model, based on the provided input.
*
* @param id A unique identifier for the chat completion. Each chunk has the same ID.
* @param object The object type, which is always 'chat.completion.chunk'.
* @param created The Unix timestamp (in seconds) of when the chat completion was created. Each chunk has the same
* timestamp.
* @param result Result of chat completion message.
*/
@JsonInclude(Include.NON_NULL)
public record ChatCompletionChunk(
@JsonProperty("id") String id,
@JsonProperty("object") String object,
@JsonProperty("created") Long created,
@JsonProperty("result") String result,
@JsonProperty("is_end") Boolean end,
@JsonProperty("usage") Usage usage
) {
}
/**
* Creates a model response for the given chat conversation.
*
* @param chatRequest The chat completion request.
* @return Entity response with {@link ChatCompletion} as a body and HTTP status code and headers.
*/
public ResponseEntity<ChatCompletion> chatCompletionEntity(ChatCompletionRequest chatRequest) {
Assert.notNull(chatRequest, "The request body can not be null.");
Assert.isTrue(!chatRequest.stream(), "Request must set the steam property to false.");
return this.restClient.post()
.uri("/v1/wenxinworkshop/chat/{model}?access_token={token}",chatRequest.model, getAccessToken())
.body(chatRequest)
.retrieve()
.toEntity(ChatCompletion.class);
}
/**
* Creates a streaming chat response for the given chat conversation.
* @param chatRequest The chat completion request. Must have the stream property set
* to true.
* @return Returns a {@link Flux} stream from chat completion chunks.
*/
public Flux<ChatCompletionChunk> chatCompletionStream(ChatCompletionRequest chatRequest) {
Assert.notNull(chatRequest, "The request body can not be null.");
Assert.isTrue(chatRequest.stream(), "Request must set the steam property to true.");
return this.webClient.post()
.uri("/v1/wenxinworkshop/chat/{model}?access_token={token}",chatRequest.model, getAccessToken())
.body(Mono.just(chatRequest), ChatCompletionRequest.class)
.retrieve()
.bodyToFlux(ChatCompletionChunk.class)
.takeUntil(SSE_DONE_PREDICATE);
}
/**
* QianFan Embeddings Models:
* <a href="https://cloud.baidu.com/doc/WENXINWORKSHOP/s/Nlks5zkzu#%E5%90%91%E9%87%8Fembeddings">Embeddings</a>.
*/
public enum EmbeddingModel {
/**
* DIMENSION: 384
*/
EMBEDDING_V1("embedding-v1"),
/**
* DIMENSION: 1024
*/
BGE_LARGE_ZH("bge_large_zh"),
/**
* DIMENSION: 1024
*/
BGE_LARGE_EN("bge_large_en"),
/**
* DIMENSION: 1024
*/
TAO_8K("tao_8k");
public final String value;
EmbeddingModel(String value) {
this.value = value;
}
public String getValue() {
return value;
}
}
/**
* Creates an embedding vector representing the input text.
*
* @param texts Input text to embed, encoded as a string or array of tokens.
* @param user A unique identifier representing your end-user, which can help QianFan to
* monitor and detect abuse.
*/
@JsonInclude(Include.NON_NULL)
public record EmbeddingRequest(
@JsonProperty("input") List<String> texts,
@JsonProperty("model") String model,
@JsonProperty("user_id") String user
) {
/**
* Create an embedding request with the given input.
* Embedding model is set to 'bge_large_zh'.
* @param text Input text to embed.
*/
public EmbeddingRequest(String text) {
this(List.of(text), DEFAULT_EMBEDDING_MODEL, null);
}
/**
* Create an embedding request with the given input.
* @param text Input text to embed.
* @param model ID of the model to use.
* @param userId A unique identifier representing your end-user, which can help QianFan to
* monitor and detect abuse.
*/
public EmbeddingRequest(String text,String model,String userId) {
this(List.of(text), model, userId);
}
/**
* Create an embedding request with the given input.
* Embedding model is set to 'bge_large_zh'.
* @param texts Input text to embed.
*/
public EmbeddingRequest(List<String> texts) {
this(texts, DEFAULT_EMBEDDING_MODEL, null);
}
/**
* Create an embedding request with the given input.
* @param texts Input text to embed.
* @param model ID of the model to use.
*/
public EmbeddingRequest(List<String> texts, String model) {
this(texts, model, null);
}
}
/**
* Represents an embedding vector returned by embedding endpoint.
*
* @param index The index of the embedding in the list of embeddings.
* @param embedding The embedding vector, which is a list of floats. The length of
* vector depends on the model.
* @param object The object type, which is always 'embedding'.
*/
@JsonInclude(Include.NON_NULL)
public record Embedding(
// @formatter:off
@JsonProperty("index") Integer index,
@JsonProperty("embedding") List<Double> embedding,
@JsonProperty("object") String object) {
// @formatter:on
/**
* Create an embedding with the given index, embedding and object type set to
* 'embedding'.
* @param index The index of the embedding in the list of embeddings.
* @param embedding The embedding vector, which is a list of floats. The length of
* vector depends on the model.
*/
public Embedding(Integer index, List<Double> embedding) {
this(index, embedding, "embedding");
}
}
/**
* List of multiple embedding responses.
*
* @param object Must have value "embedding_list".
* @param data List of entities.
* @param model ID of the model to use.
* @param usage Usage statistics for the completion request.
*/
@JsonInclude(Include.NON_NULL)
public record EmbeddingList(
// @formatter:off
@JsonProperty("object") String object,
@JsonProperty("data") List<Embedding> data,
@JsonProperty("model") String model,
@JsonProperty("error_code") String errorCode,
@JsonProperty("error_msg") String errorNsg,
@JsonProperty("usage") Usage usage) {
// @formatter:on
}
/**
* Creates an embedding vector representing the input text or token array.
* @param embeddingRequest The embedding request.
* @return Returns list of {@link Embedding} wrapped in {@link EmbeddingList}.
*/
public ResponseEntity<EmbeddingList> embeddings(EmbeddingRequest embeddingRequest) {
Assert.notNull(embeddingRequest, "The request body can not be null.");
// Input text to embed, encoded as a string or array of tokens. To embed multiple
// inputs in a single
// request, pass an array of strings or array of token arrays.
Assert.notNull(embeddingRequest.texts(), "The input can not be null.");
// The input must not an empty string, and any array must be 16 dimensions or
// less.
Assert.isTrue(!CollectionUtils.isEmpty(embeddingRequest.texts()), "The input list can not be empty.");
Assert.isTrue(embeddingRequest.texts().size() <= 16, "The list must be 16 dimensions or less");
return this.restClient.post()
.uri("/v1/wenxinworkshop/embeddings/{model}?access_token={token}", embeddingRequest.model, getAccessToken())
.body(embeddingRequest)
.retrieve()
.toEntity(new ParameterizedTypeReference<>() {
});
}
}
// @formatter:on

View File

@@ -0,0 +1,145 @@
/*
* Copyright 2023 - 2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.qianfan.api;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.springframework.ai.qianfan.api.auth.AuthApi;
import org.springframework.ai.retry.RetryUtils;
import org.springframework.http.ResponseEntity;
import org.springframework.util.Assert;
import org.springframework.web.client.ResponseErrorHandler;
import org.springframework.web.client.RestClient;
import java.util.List;
/**
* QianFan Image API.
*
* @author Geng Rong
* @since 1.0
*/
public class QianFanImageApi extends AuthApi {
public static final String DEFAULT_IMAGE_MODEL = ImageModel.Stable_Diffusion_XL.getValue();
private final RestClient restClient;
/**
* Create a new QianFan Image api with default base URL.
* @param apiKey QianFan api key.
* @param secretKey QianFan secret key.
*/
public QianFanImageApi(String apiKey, String secretKey) {
this(ApiUtils.DEFAULT_BASE_URL, apiKey, secretKey, RestClient.builder());
}
/**
* Create a new QianFan Image API with the provided base URL.
* @param baseUrl the base URL for the QianFan API.
* @param apiKey QianFan api key.
* @param secretKey QianFan secret key.
* @param restClientBuilder the rest client builder to use.
*/
public QianFanImageApi(String baseUrl, String apiKey, String secretKey, RestClient.Builder restClientBuilder) {
this(baseUrl, apiKey, secretKey, restClientBuilder, RetryUtils.DEFAULT_RESPONSE_ERROR_HANDLER);
}
/**
* Create a new QianFan Image API with the provided base URL.
* @param baseUrl the base URL for the QianFan API.
* @param apiKey QianFan api key.
* @param secretKey QianFan secret key.
* @param restClientBuilder the rest client builder to use.
* @param responseErrorHandler the response error handler to use.
*/
public QianFanImageApi(String baseUrl, String apiKey, String secretKey, RestClient.Builder restClientBuilder,
ResponseErrorHandler responseErrorHandler) {
super(apiKey, secretKey);
this.restClient = restClientBuilder.baseUrl(baseUrl)
.defaultHeaders(ApiUtils.getJsonContentHeaders())
.defaultStatusHandler(responseErrorHandler)
.build();
}
/**
* QianFan Image API model.
*/
public enum ImageModel {
/**
* Stable Diffusion XL (SDXL) is a powerful text-to-image generation model.
*/
Stable_Diffusion_XL("sd_xl");
private final String value;
ImageModel(String model) {
this.value = model;
}
public String getValue() {
return this.value;
}
}
// @formatter:off
@JsonInclude(JsonInclude.Include.NON_NULL)
public record QianFanImageRequest (
@JsonProperty("model") String model,
@JsonProperty("prompt") String prompt,
@JsonProperty("negative_prompt") String negativePrompt,
@JsonProperty("size") String size,
@JsonProperty("n") Integer n,
@JsonProperty("steps") Integer steps,
@JsonProperty("seed") Integer seed,
@JsonProperty("style") String style,
@JsonProperty("user_id") String user) {
public QianFanImageRequest(String prompt, String model) {
this(model, prompt, null, null, null, null, null, null, null);
}
}
@JsonInclude(JsonInclude.Include.NON_NULL)
public record QianFanImageResponse(
@JsonProperty("id") String id,
@JsonProperty("created") Long created,
@JsonProperty("data") List<Data> data) {
}
@JsonInclude(JsonInclude.Include.NON_NULL)
public record Data(
@JsonProperty("index") Integer index,
@JsonProperty("b64_image") String b64Image) {
}
// @formatter:onn
public ResponseEntity<QianFanImageResponse> createImage(QianFanImageRequest qianFanImageRequest) {
Assert.notNull(qianFanImageRequest, "Image request cannot be null.");
Assert.hasLength(qianFanImageRequest.prompt(), "Prompt cannot be empty.");
return this.restClient.post()
.uri("/v1/wenxinworkshop/text2image/{model}?access_token={token}", qianFanImageRequest.model(),
getAccessToken())
.body(qianFanImageRequest)
.retrieve()
.toEntity(QianFanImageResponse.class);
}
}

View File

@@ -0,0 +1,15 @@
package org.springframework.ai.qianfan.api.auth;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* Represents the response received when requesting an access token.
*
* @author Geng Rong
* @since 1.0
*/
public record AccessTokenResponse(@JsonProperty("access_token") String accessToken,
@JsonProperty("refresh_token") String refreshToken, @JsonProperty("expires_in") Long expiresIn,
@JsonProperty("session_key") String sessionKey, @JsonProperty("session_secret") String sessionSecret,
@JsonProperty("error") String error, @JsonProperty("error_description") String errorDescription, String scope) {
}

View File

@@ -0,0 +1,31 @@
package org.springframework.ai.qianfan.api.auth;
/**
* QianFan abstract authentication API.
*
* @author Geng Rong
* @since 1.0
*/
public abstract class AuthApi {
private final QianFanAuthenticator authenticator;
private QianFanAccessToken token;
/**
* Create a new chat completion api with default base URL.
* @param apiKey QianFan api key.
* @param secretKey QianFan secret key.
*/
protected AuthApi(String apiKey, String secretKey) {
this.authenticator = QianFanAuthenticator.builder().apiKey(apiKey).secretKey(secretKey).build();
}
protected String getAccessToken() {
if (this.token == null || this.token.needsRefresh()) {
this.token = this.authenticator.requestToken();
}
return this.token.getAccessToken();
}
}

View File

@@ -0,0 +1,73 @@
package org.springframework.ai.qianfan.api.auth;
/**
* Represents an access token for the QianFan API.
*
* @author Geng Rong
* @since 1.0
*/
public class QianFanAccessToken {
private static final Double FRACTION_OF_TIME_TO_LIVE = 0.8D;
private final String accessToken;
private final String refreshToken;
private final Long expiresIn;
private final String sessionKey;
private final String sessionSecret;
private final String scope;
private final Long refreshTime;
public QianFanAccessToken(AccessTokenResponse accessTokenResponse) {
this.accessToken = accessTokenResponse.accessToken();
this.refreshToken = accessTokenResponse.refreshToken();
this.expiresIn = accessTokenResponse.expiresIn();
this.sessionKey = accessTokenResponse.sessionKey();
this.sessionSecret = accessTokenResponse.sessionSecret();
this.scope = accessTokenResponse.scope();
this.refreshTime = getCurrentTimeInSeconds() + (long) ((double) expiresIn * FRACTION_OF_TIME_TO_LIVE);
}
public String getAccessToken() {
return accessToken;
}
public String getRefreshToken() {
return refreshToken;
}
public Long getExpiresIn() {
return expiresIn;
}
public String getSessionKey() {
return sessionKey;
}
public String getSessionSecret() {
return sessionSecret;
}
public Long getRefreshTime() {
return refreshTime;
}
public String getScope() {
return scope;
}
public synchronized boolean needsRefresh() {
return getCurrentTimeInSeconds() >= this.refreshTime;
}
private long getCurrentTimeInSeconds() {
return System.currentTimeMillis() / 1000L;
}
}

View File

@@ -0,0 +1,75 @@
package org.springframework.ai.qianfan.api.auth;
import org.springframework.http.ResponseEntity;
import org.springframework.web.client.RestClient;
/**
* QianFanAuthenticator is a class that authenticates and requests access token for the
* QianFan API.
*
* @author Geng Rong
* @since 1.0
*/
public class QianFanAuthenticator {
private static final String DEFAULT_AUTH_URL = "https://aip.baidubce.com";
private static final String OPERATION_PATH = "/oauth/2.0/token?client_id={clientId}&client_secret={clientSecret}&grant_type=client_credentials";
private final RestClient restClient;
private final String apiKey;
private final String secretKey;
public QianFanAuthenticator(String authUrl, String apiKey, String secretKey) {
this.apiKey = apiKey;
this.secretKey = secretKey;
this.restClient = RestClient.builder().baseUrl(authUrl).build();
}
public QianFanAccessToken requestToken() {
ResponseEntity<AccessTokenResponse> tokenResponseEntity = this.restClient.get()
.uri(OPERATION_PATH, apiKey, secretKey)
.retrieve()
.toEntity(AccessTokenResponse.class);
AccessTokenResponse tokenResponse = tokenResponseEntity.getBody();
if (tokenResponse == null) {
throw new IllegalArgumentException("Failed to get access token, response is null");
}
if (tokenResponse.error() != null) {
throw new IllegalArgumentException("Failed to get access token, error: " + tokenResponse.error()
+ ", error_description: " + tokenResponse.errorDescription());
}
return new QianFanAccessToken(tokenResponse);
}
public static class Builder {
private String apiKey;
private String secretKey;
public Builder apiKey(String apiKey) {
this.apiKey = apiKey;
return this;
}
public Builder secretKey(String secretKey) {
this.secretKey = secretKey;
return this;
}
public QianFanAuthenticator build() {
return new QianFanAuthenticator(DEFAULT_AUTH_URL, apiKey, secretKey);
}
}
public static Builder builder() {
return new Builder();
}
}

View File

@@ -0,0 +1,2 @@
org.springframework.aot.hint.RuntimeHintsRegistrar=\
org.springframework.ai.qianfan.aot.QianFanRuntimeHints

View File

@@ -0,0 +1,53 @@
/*
* Copyright 2023 - 2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.qianfan;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.qianfan.api.QianFanApi;
import static org.assertj.core.api.Assertions.assertThat;
/**
* @author Geng Rong
*/
public class ChatCompletionRequestTests {
@Test
public void createRequestWithChatOptions() {
var client = new QianFanChatModel(new QianFanApi("TEST", "TEST"),
QianFanChatOptions.builder().withModel("DEFAULT_MODEL").withTemperature(66.6f).build());
var request = client.createRequest(new Prompt("Test message content"), false);
assertThat(request.messages()).hasSize(1);
assertThat(request.stream()).isFalse();
assertThat(request.model()).isEqualTo("DEFAULT_MODEL");
assertThat(request.temperature()).isEqualTo(66.6f);
request = client.createRequest(new Prompt("Test message content",
QianFanChatOptions.builder().withModel("PROMPT_MODEL").withTemperature(99.9f).build()), true);
assertThat(request.messages()).hasSize(1);
assertThat(request.stream()).isTrue();
assertThat(request.model()).isEqualTo("PROMPT_MODEL");
assertThat(request.temperature()).isEqualTo(99.9f);
}
}

View File

@@ -0,0 +1,75 @@
/*
* Copyright 2023 - 2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.qianfan;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.ai.image.ImageModel;
import org.springframework.ai.qianfan.api.QianFanApi;
import org.springframework.ai.qianfan.api.QianFanImageApi;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.util.StringUtils;
/**
* @author Geng Rong
*/
@SpringBootConfiguration
public class QianFanTestConfiguration {
@Bean
public QianFanApi qianFanApi() {
return new QianFanApi(getApiKey(), getSecretKey());
}
@Bean
public QianFanImageApi qianFanImageApi() {
return new QianFanImageApi(getApiKey(), getSecretKey());
}
private String getApiKey() {
String apiKey = System.getenv("QIANFAN_API_KEY");
if (!StringUtils.hasText(apiKey)) {
throw new IllegalArgumentException(
"You must provide an API key. Put it in an environment variable under the name QIANFAN_API_KEY");
}
return apiKey;
}
private String getSecretKey() {
String apiKey = System.getenv("QIANFAN_SECRET_KEY");
if (!StringUtils.hasText(apiKey)) {
throw new IllegalArgumentException(
"You must provide a secret key. Put it in an environment variable under the name QIANFAN_SECRET_KEY");
}
return apiKey;
}
@Bean
public QianFanChatModel qianFanChatModel(QianFanApi api) {
return new QianFanChatModel(api);
}
@Bean
public EmbeddingModel qianFanEmbeddingModel(QianFanApi api) {
return new QianFanEmbeddingModel(api);
}
@Bean
public ImageModel qianFanImageModel(QianFanImageApi api) {
return new QianFanImageModel(api);
}
}

View File

@@ -0,0 +1,81 @@
/*
* Copyright 2023 - 2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.qianfan.api;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariables;
import org.springframework.ai.ResourceUtils;
import org.springframework.ai.qianfan.api.QianFanApi.ChatCompletion;
import org.springframework.ai.qianfan.api.QianFanApi.ChatCompletionChunk;
import org.springframework.ai.qianfan.api.QianFanApi.ChatCompletionMessage;
import org.springframework.ai.qianfan.api.QianFanApi.ChatCompletionMessage.Role;
import org.springframework.ai.qianfan.api.QianFanApi.ChatCompletionRequest;
import org.springframework.ai.qianfan.api.QianFanApi.EmbeddingList;
import org.springframework.http.ResponseEntity;
import org.stringtemplate.v4.ST;
import reactor.core.publisher.Flux;
import java.util.List;
import java.util.Objects;
import static org.assertj.core.api.Assertions.assertThat;
/**
* @author Geng Rong
*/
@EnabledIfEnvironmentVariables(value = { @EnabledIfEnvironmentVariable(named = "QIANFAN_API_KEY", matches = ".+"),
@EnabledIfEnvironmentVariable(named = "QIANFAN_SECRET_KEY", matches = ".+") })
public class QianFanApiIT {
QianFanApi qianFanApi = new QianFanApi(System.getenv("QIANFAN_API_KEY"), System.getenv("QIANFAN_SECRET_KEY"));
@Test
void chatCompletionEntity() {
ChatCompletionMessage chatCompletionMessage = new ChatCompletionMessage("Hello world", Role.USER);
ResponseEntity<ChatCompletion> response = qianFanApi.chatCompletionEntity(new ChatCompletionRequest(
List.of(chatCompletionMessage), buildSystemMessage(), "ernie_speed", 0.7f, false));
assertThat(response).isNotNull();
assertThat(response.getBody()).isNotNull();
}
@Test
void chatCompletionStream() {
ChatCompletionMessage chatCompletionMessage = new ChatCompletionMessage("Hello world", Role.USER);
Flux<ChatCompletionChunk> response = qianFanApi.chatCompletionStream(new ChatCompletionRequest(
List.of(chatCompletionMessage), buildSystemMessage(), "ernie_speed", 0.7f, true));
assertThat(response).isNotNull();
assertThat(response.collectList().block()).isNotNull();
}
@Test
void embeddings() {
ResponseEntity<EmbeddingList> response = qianFanApi.embeddings(new QianFanApi.EmbeddingRequest("Hello world"));
assertThat(response).isNotNull();
assertThat(Objects.requireNonNull(response.getBody()).data()).hasSize(1);
assertThat(response.getBody().data().get(0).embedding()).hasSize(1024);
}
private String buildSystemMessage() {
String systemMessageTemplate = ResourceUtils.getText("classpath:/prompts/system-message.st");
ST st = new ST(systemMessageTemplate, '{', '}');
return st.add("name", "QianFan").add("voice", "pirate").render();
}
}

View File

@@ -0,0 +1,210 @@
/*
* Copyright 2023 - 2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.qianfan.api;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.document.MetadataMode;
import org.springframework.ai.image.ImageMessage;
import org.springframework.ai.image.ImagePrompt;
import org.springframework.ai.qianfan.QianFanChatModel;
import org.springframework.ai.qianfan.QianFanChatOptions;
import org.springframework.ai.qianfan.QianFanEmbeddingModel;
import org.springframework.ai.qianfan.QianFanEmbeddingOptions;
import org.springframework.ai.qianfan.QianFanImageModel;
import org.springframework.ai.qianfan.QianFanImageOptions;
import org.springframework.ai.qianfan.api.QianFanApi.ChatCompletion;
import org.springframework.ai.qianfan.api.QianFanApi.ChatCompletionChunk;
import org.springframework.ai.qianfan.api.QianFanApi.ChatCompletionRequest;
import org.springframework.ai.qianfan.api.QianFanApi.EmbeddingList;
import org.springframework.ai.qianfan.api.QianFanApi.EmbeddingRequest;
import org.springframework.ai.qianfan.api.QianFanApi.Usage;
import org.springframework.ai.qianfan.api.QianFanImageApi.Data;
import org.springframework.ai.qianfan.api.QianFanImageApi.QianFanImageRequest;
import org.springframework.ai.qianfan.api.QianFanImageApi.QianFanImageResponse;
import org.springframework.ai.retry.RetryUtils;
import org.springframework.ai.retry.TransientAiException;
import org.springframework.http.ResponseEntity;
import org.springframework.retry.RetryCallback;
import org.springframework.retry.RetryContext;
import org.springframework.retry.RetryListener;
import org.springframework.retry.support.RetryTemplate;
import reactor.core.publisher.Flux;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.isA;
import static org.mockito.Mockito.when;
/**
* @author Geng Rong
*/
@ExtendWith(MockitoExtension.class)
public class QianFanRetryTests {
private static class TestRetryListener implements RetryListener {
int onErrorRetryCount = 0;
int onSuccessRetryCount = 0;
@Override
public <T, E extends Throwable> void onSuccess(RetryContext context, RetryCallback<T, E> callback, T result) {
onSuccessRetryCount = context.getRetryCount();
}
@Override
public <T, E extends Throwable> void onError(RetryContext context, RetryCallback<T, E> callback,
Throwable throwable) {
onErrorRetryCount = context.getRetryCount();
}
}
private TestRetryListener retryListener;
private @Mock QianFanApi qianFanApi;
private @Mock QianFanImageApi qianFanImageApi;
private QianFanChatModel chatClient;
private QianFanEmbeddingModel embeddingClient;
private QianFanImageModel imageModel;
@BeforeEach
public void beforeEach() {
RetryTemplate retryTemplate = RetryUtils.DEFAULT_RETRY_TEMPLATE;
retryListener = new TestRetryListener();
retryTemplate.registerListener(retryListener);
chatClient = new QianFanChatModel(qianFanApi, QianFanChatOptions.builder().build(), retryTemplate);
embeddingClient = new QianFanEmbeddingModel(qianFanApi, MetadataMode.EMBED,
QianFanEmbeddingOptions.builder().build(), retryTemplate);
imageModel = new QianFanImageModel(qianFanImageApi, QianFanImageOptions.builder().build(), retryTemplate);
}
@Test
public void qianFanChatTransientError() {
ChatCompletion expectedChatCompletion = new ChatCompletion("id", "chat.completion", 666L, "Response",
new Usage(10, 10));
when(qianFanApi.chatCompletionEntity(isA(ChatCompletionRequest.class)))
.thenThrow(new TransientAiException("Transient Error 1"))
.thenThrow(new TransientAiException("Transient Error 2"))
.thenReturn(ResponseEntity.of(Optional.of(expectedChatCompletion)));
var result = chatClient.call(new Prompt("text"));
assertThat(result).isNotNull();
assertThat(result.getResult().getOutput().getContent()).isSameAs("Response");
assertThat(retryListener.onSuccessRetryCount).isEqualTo(2);
assertThat(retryListener.onErrorRetryCount).isEqualTo(2);
}
@Test
public void qianFanChatNonTransientError() {
when(qianFanApi.chatCompletionEntity(isA(ChatCompletionRequest.class))).thenThrow(new RuntimeException("Non Transient Error"));
assertThrows(RuntimeException.class, () -> chatClient.call(new Prompt("text")));
}
@Test
public void qianFanChatStreamTransientError() {
ChatCompletionChunk expectedChatCompletion = new ChatCompletionChunk("id", "chat.completion", 666L, "Response",
true, null);
when(qianFanApi.chatCompletionStream(isA(ChatCompletionRequest.class)))
.thenThrow(new TransientAiException("Transient Error 1"))
.thenThrow(new TransientAiException("Transient Error 2"))
.thenReturn(Flux.just(expectedChatCompletion));
var result = chatClient.stream(new Prompt("text"));
assertThat(result).isNotNull();
assertThat(Objects.requireNonNull(result.collectList().block()).get(0).getResult().getOutput().getContent())
.isSameAs("Response");
assertThat(retryListener.onSuccessRetryCount).isEqualTo(2);
assertThat(retryListener.onErrorRetryCount).isEqualTo(2);
}
@Test
public void qianFanChatStreamNonTransientError() {
when(qianFanApi.chatCompletionStream(isA(ChatCompletionRequest.class))).thenThrow(new RuntimeException("Non Transient Error"));
assertThrows(RuntimeException.class, () -> chatClient.stream(new Prompt("text")));
}
@Test
public void qianFanEmbeddingTransientError() {
QianFanApi.Embedding embedding = new QianFanApi.Embedding(1, List.of(9.9, 8.8));
EmbeddingList expectedEmbeddings = new EmbeddingList("embedding_list", List.of(embedding), "model", null, null,
new Usage(10, 10));
when(qianFanApi.embeddings(isA(EmbeddingRequest.class)))
.thenThrow(new TransientAiException("Transient Error 1"))
.thenThrow(new TransientAiException("Transient Error 2"))
.thenReturn(ResponseEntity.of(Optional.of(expectedEmbeddings)));
var result = embeddingClient
.call(new org.springframework.ai.embedding.EmbeddingRequest(List.of("text1", "text2"), null));
assertThat(result).isNotNull();
assertThat(result.getResult().getOutput()).isEqualTo(List.of(9.9, 8.8));
assertThat(retryListener.onSuccessRetryCount).isEqualTo(2);
assertThat(retryListener.onErrorRetryCount).isEqualTo(2);
}
@Test
public void qianFanEmbeddingNonTransientError() {
when(qianFanApi.embeddings(isA(EmbeddingRequest.class))).thenThrow(new RuntimeException("Non Transient Error"));
assertThrows(RuntimeException.class, () -> embeddingClient.call(new org.springframework.ai.embedding.EmbeddingRequest(List.of("text1", "text2"), null)));
}
@Test
public void qianFanImageTransientError() {
var expectedResponse = new QianFanImageResponse("1", 678L, List.of(new Data(1, "b64")));
when(qianFanImageApi.createImage(isA(QianFanImageRequest.class)))
.thenThrow(new TransientAiException("Transient Error 1"))
.thenThrow(new TransientAiException("Transient Error 2"))
.thenReturn(ResponseEntity.of(Optional.of(expectedResponse)));
var result = imageModel.call(new ImagePrompt(List.of(new ImageMessage("Image Message"))));
assertThat(result).isNotNull();
assertThat(result.getResult().getOutput().getB64Json()).isEqualTo("b64");
assertThat(retryListener.onSuccessRetryCount).isEqualTo(2);
assertThat(retryListener.onErrorRetryCount).isEqualTo(2);
}
@Test
public void qianFanImageNonTransientError() {
when(qianFanImageApi.createImage(isA(QianFanImageRequest.class)))
.thenThrow(new RuntimeException("Transient Error 1"));
assertThrows(RuntimeException.class,
() -> imageModel.call(new ImagePrompt(List.of(new ImageMessage("Image Message")))));
}
}

View File

@@ -0,0 +1,75 @@
/*
* Copyright 2023 - 2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.qianfan.embedding;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariables;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.ai.embedding.EmbeddingResponse;
import org.springframework.ai.qianfan.QianFanTestConfiguration;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
/**
* @author Geng Rong
*/
@SpringBootTest(classes = QianFanTestConfiguration.class)
@EnabledIfEnvironmentVariables(value = { @EnabledIfEnvironmentVariable(named = "QIANFAN_API_KEY", matches = ".+"),
@EnabledIfEnvironmentVariable(named = "QIANFAN_SECRET_KEY", matches = ".+") })
class EmbeddingIT {
@Autowired
private EmbeddingModel embeddingModel;
@Test
void defaultEmbedding() {
Assertions.assertThat(embeddingModel).isNotNull();
EmbeddingResponse embeddingResponse = embeddingModel.embedForResponse(List.of("Hello World"));
assertThat(embeddingResponse.getResults()).hasSize(1);
assertThat(embeddingResponse.getResults().get(0)).isNotNull();
assertThat(embeddingResponse.getResults().get(0).getOutput()).hasSize(1024);
Assertions.assertThat(embeddingModel.dimensions()).isEqualTo(1024);
}
@Test
void batchEmbedding() {
Assertions.assertThat(embeddingModel).isNotNull();
EmbeddingResponse embeddingResponse = embeddingModel.embedForResponse(List.of("Hello World", "HI"));
assertThat(embeddingResponse.getResults()).hasSize(2);
assertThat(embeddingResponse.getResults().get(0)).isNotNull();
assertThat(embeddingResponse.getResults().get(0).getOutput()).hasSize(1024);
assertThat(embeddingResponse.getResults().get(1)).isNotNull();
assertThat(embeddingResponse.getResults().get(1).getOutput()).hasSize(1024);
Assertions.assertThat(embeddingModel.dimensions()).isEqualTo(1024);
}
}

View File

@@ -0,0 +1,66 @@
/*
* Copyright 2023 - 2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.qianfan.image;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariables;
import org.springframework.ai.image.Image;
import org.springframework.ai.image.ImageModel;
import org.springframework.ai.image.ImageOptionsBuilder;
import org.springframework.ai.image.ImagePrompt;
import org.springframework.ai.image.ImageResponse;
import org.springframework.ai.image.ImageResponseMetadata;
import org.springframework.ai.qianfan.QianFanTestConfiguration;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import static org.assertj.core.api.Assertions.assertThat;
/**
* @author Geng Rong
*/
@SpringBootTest(classes = QianFanTestConfiguration.class)
@EnabledIfEnvironmentVariables(value = { @EnabledIfEnvironmentVariable(named = "QIANFAN_API_KEY", matches = ".+"),
@EnabledIfEnvironmentVariable(named = "QIANFAN_SECRET_KEY", matches = ".+") })
public class QianFanImageModelIT {
@Autowired
protected ImageModel imageModel;
@Test
void imageTest() {
var options = ImageOptionsBuilder.builder().withHeight(1024).withWidth(1024).build();
var instructions = """
A light cream colored mini golden doodle with a sign that contains the message "I'm on my way to BARCADE!".""";
ImagePrompt imagePrompt = new ImagePrompt(instructions, options);
ImageResponse imageResponse = imageModel.call(imagePrompt);
assertThat(imageResponse.getResults()).hasSize(1);
ImageResponseMetadata imageResponseMetadata = imageResponse.getMetadata();
assertThat(imageResponseMetadata.getCreated()).isPositive();
var generation = imageResponse.getResult();
Image image = generation.getOutput();
assertThat(image.getUrl()).isNull();
assertThat(image.getB64Json()).isNotEmpty();
}
}

View File

@@ -0,0 +1,3 @@
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}.

View File

@@ -325,7 +325,7 @@ public class ZhiPuAiApi {
/**
* Specifying a particular function forces the model to call that function.
*/
public static Object FUNCTION(String functionName) {
public static Object function(String functionName) {
return Map.of("type", "function", "function", Map.of("name", functionName));
}
}

View File

@@ -69,6 +69,7 @@
<module>models/spring-ai-ollama</module>
<module>models/spring-ai-openai</module>
<module>models/spring-ai-postgresml</module>
<module>models/spring-ai-qianfan</module>
<module>models/spring-ai-stability-ai</module>
<module>models/spring-ai-transformers</module>
<module>models/spring-ai-vertex-ai-gemini</module>
@@ -86,6 +87,7 @@
<module>spring-ai-spring-boot-starters/spring-ai-starter-ollama</module>
<module>spring-ai-spring-boot-starters/spring-ai-starter-openai</module>
<module>spring-ai-spring-boot-starters/spring-ai-starter-postgresml-embedding</module>
<module>spring-ai-spring-boot-starters/spring-ai-starter-qianfan</module>
<module>spring-ai-spring-boot-starters/spring-ai-starter-stability-ai</module>
<module>spring-ai-spring-boot-starters/spring-ai-starter-transformers</module>
<module>spring-ai-spring-boot-starters/spring-ai-starter-vertex-ai-gemini</module>

View File

@@ -133,6 +133,12 @@
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-moonshot</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-qianfan</artifactId>
<version>${project.version}</version>
</dependency>
@@ -474,6 +480,13 @@
<artifactId>spring-ai-moonshot-spring-boot-starter</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-qianfan-spring-boot-starter</artifactId>
<version>${project.version}</version>
</dependency>
</dependencies>
</dependencyManagement>

View File

@@ -32,6 +32,7 @@
**** xref:api/chat/functions/minimax-chat-functions.adoc[Function Calling]
*** xref:api/chat/moonshot-chat.adoc[Moonshot AI]
**** xref:api/chat/functions/moonshot-chat-functions.adoc[Function Calling]
*** xref:api/chat/qianfan-chat.adoc[QianFan]
** xref:api/embeddings.adoc[]
*** xref:api/embeddings/openai-embeddings.adoc[OpenAI]
*** xref:api/embeddings/ollama-embeddings.adoc[Ollama]
@@ -45,11 +46,13 @@
*** xref:api/embeddings/mistralai-embeddings.adoc[Mistral AI]
*** xref:api/embeddings/minimax-embeddings.adoc[MiniMax]
*** xref:api/embeddings/zhipuai-embeddings.adoc[ZhiPu AI]
*** xref:api/embeddings/qianfan-embeddings.adoc[QianFan]
** xref:api/imageclient.adoc[]
*** xref:api/image/azure-openai-image.adoc[Azure OpenAI]
*** xref:api/image/openai-image.adoc[OpenAI]
*** xref:api/image/stabilityai-image.adoc[Stability]
*** xref:api/image/zhipuai-image.adoc[ZhiPuAI]
*** xref:api/image/qianfan-image.adoc[QianFan]
** xref:api/audio[Audio Model API]
*** xref:api/audio/transcriptions.adoc[]
**** xref:api/audio/transcriptions/openai-transcriptions.adoc[OpenAI]

View File

@@ -0,0 +1,254 @@
= QianFan Chat
Spring AI supports the various AI language models from QianFan. You can interact with QianFan language models and create a multilingual conversational assistant based on QianFan models.
== Prerequisites
You will need to create an API with QianFan to access QianFan language models.
Create an account at https://login.bce.baidu.com/new-reg[QianFan registration page] and generate the token on the https://console.bce.baidu.com/qianfan/ais/console/applicationConsole/application[API Keys page].
The Spring AI project defines a configuration property named `spring.ai.qianfan.api-key` and `spring.ai.qianfan.secret-key`.
you should set to the value of the `API Key` and `Secret Key` obtained from https://console.bce.baidu.com/qianfan/ais/console/applicationConsole/application[API Keys page].
Exporting an environment variable is one way to set that configuration property:
[source,shell]
----
export SPRING_AI_QIANFAN_API_KEY=<INSERT KEY HERE>
----
=== Add Repositories and BOM
Spring AI artifacts are published in Spring Milestone and Snapshot repositories.
Refer to the xref:getting-started.adoc#repositories[Repositories] section to add these repositories to your build system.
To help with dependency management, Spring AI provides a BOM (bill of materials) to ensure that a consistent version of Spring AI is used throughout the entire project. Refer to the xref:getting-started.adoc#dependency-management[Dependency Management] section to add the Spring AI BOM to your build system.
== Auto-configuration
Spring AI provides Spring Boot auto-configuration for the QianFan Chat Client.
To enable it add the following dependency to your project's Maven `pom.xml` file:
[source, xml]
----
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-qianfan-spring-boot-starter</artifactId>
</dependency>
----
or to your Gradle `build.gradle` build file.
[source,groovy]
----
dependencies {
implementation 'org.springframework.ai:spring-ai-qianfan-spring-boot-starter'
}
----
TIP: Refer to the xref:getting-started.adoc#dependency-management[Dependency Management] section to add the Spring AI BOM to your build file.
=== Chat Properties
==== Retry Properties
The prefix `spring.ai.retry` is used as the property prefix that lets you configure the retry mechanism for the QianFan Chat client.
[cols="3,5,1"]
|====
| Property | Description | Default
| spring.ai.retry.max-attempts | Maximum number of retry attempts. | 10
| spring.ai.retry.backoff.initial-interval | Initial sleep duration for the exponential backoff policy. | 2 sec.
| spring.ai.retry.backoff.multiplier | Backoff interval multiplier. | 5
| spring.ai.retry.backoff.max-interval | Maximum backoff duration. | 3 min.
| spring.ai.retry.on-client-errors | If false, throw a NonTransientAiException, and do not attempt retry for `4xx` client error codes | false
| spring.ai.retry.exclude-on-http-codes | List of HTTP status codes that should not trigger a retry (e.g. to throw NonTransientAiException). | empty
| spring.ai.retry.on-http-codes | List of HTTP status codes that should trigger a retry (e.g. to throw TransientAiException). | empty
|====
==== Connection Properties
The prefix `spring.ai.qianfan` is used as the property prefix that lets you connect to QianFan.
[cols="3,5,1"]
|====
| Property | Description | Default
| spring.ai.qianfan.base-url | The URL to connect to | https://api.qianfan.chat
| spring.ai.qianfan.api-key | The API Key | -
| spring.ai.qianfan.secret-key | The Secret Key | -
|====
==== Configuration Properties
The prefix `spring.ai.qianfan.chat` is the property prefix that lets you configure the chat client implementation for QianFan.
[cols="3,5,1"]
|====
| Property | Description | Default
| spring.ai.qianfan.chat.enabled | Enable QianFan chat client. | true
| spring.ai.qianfan.chat.base-url | Optional overrides the spring.ai.qianfan.base-url to provide chat specific url | https://api.qianfan.chat
| spring.ai.qianfan.chat.api-key | Optional overrides the spring.ai.qianfan.api-key to provide chat specific api-key | -
| spring.ai.qianfan.chat.secret-key | Optional overrides the spring.ai.qianfan.secret-key to provide chat specific secret-key | -
| spring.ai.qianfan.chat.options.model | This is the QianFan Chat model to use | `abab5.5-chat` (the `abab5.5s-chat`, `abab5.5-chat`, and `abab6-chat` point to the latest model versions)
| spring.ai.qianfan.chat.options.maxTokens | 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. | -
| spring.ai.qianfan.chat.options.temperature | The sampling temperature to use that controls the apparent creativity of generated completions. Higher values will make output more random while lower values will make results more focused and deterministic. It is not recommended to modify temperature and top_p for the same completions request as the interaction of these two settings is difficult to predict. | 0.7
| spring.ai.qianfan.chat.options.topP | 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. | 1.0
| spring.ai.qianfan.chat.options.presencePenalty | 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. | 0.0f
| spring.ai.qianfan.chat.options.frequencyPenalty | 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. | 0.0f
| spring.ai.qianfan.chat.options.stop | The model will stop generating characters specified by stop, and currently only supports a single stop word in the format of ["stop_word1"] | -
|====
NOTE: You can override the common `spring.ai.qianfan.base-url`, `spring.ai.qianfan.chat.api-key` and `spring.ai.qianfan.chat.secret-key` for the `ChatClient` implementations.
The `spring.ai.qianfan.chat.base-url`, `spring.ai.qianfan.chat.api-key` and `spring.ai.qianfan.chat.secret-key` properties if set take precedence over the common properties.
This is useful if you want to use different QianFan accounts for different models and different model endpoints.
TIP: All properties prefixed with `spring.ai.qianfan.chat.options` can be overridden at runtime by adding a request specific <<chat-options>> to the `Prompt` call.
== Runtime Options [[chat-options]]
The link:https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-qianfan/src/main/java/org/springframework/ai/qianfan/QianFanChatOptions.java[QianFanChatOptions.java] provides model configurations, such as the model to use, the temperature, the frequency penalty, etc.
On start-up, the default options can be configured with the `QianFanChatModel(api, options)` constructor or the `spring.ai.qianfan.chat.options.*` properties.
At run-time you can override the default options by adding new, request specific, options to the `Prompt` call.
For example to override the default model and temperature for a specific request:
[source,java]
----
ChatResponse response = chatClient.call(
new Prompt(
"Generate the names of 5 famous pirates.",
QianFanChatOptions.builder()
.withModel(QianFanApi.ChatModel.ERNIE_Speed_8K.getValue())
.withTemperature(0.5f)
.build()
));
----
TIP: In addition to the model specific link:https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-qianfan/src/main/java/org/springframework/ai/qianfan/QianFanChatOptions.java[QianFanChatOptions] you can use a portable https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/chat/ChatOptions.java[ChatOptions] instance, created with the https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/chat/ChatOptionsBuilder.java[ChatOptionsBuilder#builder()].
== Sample Controller
https://start.spring.io/[Create] a new Spring Boot project and add the `spring-ai-qianfan-spring-boot-starter` to your pom (or gradle) dependencies.
Add a `application.properties` file, under the `src/main/resources` directory, to enable and configure the QianFan Chat client:
[source,application.properties]
----
spring.ai.qianfan.api-key=YOUR_API_KEY
spring.ai.qianfan.secret-key=YOUR_SECRET_KEY
spring.ai.qianfan.chat.options.model=ernie_speed
spring.ai.qianfan.chat.options.temperature=0.7
----
TIP: replace the `api-key` and `secret-key` with your QianFan credentials.
This will create a `QianFanChatModel` implementation that you can inject into your class.
Here is an example of a simple `@Controller` class that uses the chat client for text generations.
[source,java]
----
@RestController
public class ChatController {
private final QianFanChatModel chatClient;
@Autowired
public ChatController(QianFanChatModel chatClient) {
this.chatClient = chatClient;
}
@GetMapping("/ai/generate")
public Map generate(@RequestParam(value = "message", defaultValue = "Tell me a joke") String message) {
return Map.of("generation", chatClient.call(message));
}
@GetMapping("/ai/generateStream")
public Flux<ChatResponse> generateStream(@RequestParam(value = "message", defaultValue = "Tell me a joke") String message) {
var prompt = new Prompt(new UserMessage(message));
return chatClient.stream(prompt);
}
}
----
== Manual Configuration
The link:https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-qianfan/src/main/java/org/springframework/ai/qianfan/QianFanChatModel.java[QianFanChatModel] implements the `ChatClient` and `StreamingChatClient` and uses the <<low-level-api>> to connect to the QianFan service.
Add the `spring-ai-qianfan` dependency to your project's Maven `pom.xml` file:
[source, xml]
----
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-qianfan</artifactId>
</dependency>
----
or to your Gradle `build.gradle` build file.
[source,groovy]
----
dependencies {
implementation 'org.springframework.ai:spring-ai-qianfan'
}
----
TIP: Refer to the xref:getting-started.adoc#dependency-management[Dependency Management] section to add the Spring AI BOM to your build file.
Next, create a `QianFanChatModel` and use it for text generations:
[source,java]
----
var qianFanApi = new QianFanApi(System.getenv("QIANFAN_API_KEY"), System.getenv("QIANFAN_SECRET_KEY"));
var chatClient = new QianFanChatModel(qianFanApi, QianFanChatOptions.builder()
.withModel(QianFanApi.ChatModel.ERNIE_Speed_8K.getValue())
.withTemperature(0.4f)
.withMaxTokens(200)
.build());
ChatResponse response = chatClient.call(
new Prompt("Generate the names of 5 famous pirates."));
// Or with streaming responses
Flux<ChatResponse> streamResponse = chatClient.stream(
new Prompt("Generate the names of 5 famous pirates."));
----
The `QianFanChatOptions` provides the configuration information for the chat requests.
The `QianFanChatOptions.Builder` is fluent options builder.
=== Low-level QianFanApi Client [[low-level-api]]
The link:https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-qianfan/src/main/java/org/springframework/ai/qianfan/api/QianFanApi.java[QianFanApi] provides is lightweight Java client for link:https://cloud.baidu.com/doc/WENXINWORKSHOP/s/flfmc9do2[QianFan API].
Here is a simple snippet how to use the api programmatically:
[source,java]
----
String systemMessage = "Your name is QianWen";
QianFanApi qianFanApi =
new QianFanApi(System.getenv("QIANFAN_API_KEY"), System.getenv("QIANFAN_SECRET_KEY"));
ChatCompletionMessage chatCompletionMessage =
new ChatCompletionMessage("Hello world", Role.USER);
// Sync request
ResponseEntity<ChatCompletion> response = qianFanApi.chatCompletionEntity(
new ChatCompletionRequest(List.of(chatCompletionMessage), systemMessage, QianFanApi.ChatModel.ERNIE_Speed_8K.getValue(), 0.7f, false));
// Streaming request
Flux<ChatCompletionChunk> streamResponse = qianFanApi.chatCompletionStream(
new ChatCompletionRequest(List.of(chatCompletionMessage), systemMessage, QianFanApi.ChatModel.ERNIE_Speed_8K.getValue(), 0.7f, true));
----
Follow the https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-qianfan/src/main/java/org/springframework/ai/qianfan/api/QianFanApi.java[QianFanApi.java]'s JavaDoc for further information.
==== QianFanApi Samples
* The link:https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-qianfan/src/test/java/org/springframework/ai/qianfan/api/QianFanApiIT.java[QianFanApiIT.java] test provides some general examples how to use the lightweight library.

View File

@@ -0,0 +1,202 @@
= QianFan Chat
Spring AI supports the various AI language models from QianFan. You can interact with QianFan language models and create a multilingual conversational assistant based on QianFan models.
== Prerequisites
You will need to create an API with QianFan to access QianFan language models.
Create an account at https://login.bce.baidu.com/new-reg[QianFan registration page] and generate the token on the https://console.bce.baidu.com/qianfan/ais/console/applicationConsole/application[API Keys page].
The Spring AI project defines a configuration property named `spring.ai.qianfan.api-key` and `spring.ai.qianfan.secret-key`.
you should set to the value of the `API Key` and `Secret Key` obtained from https://console.bce.baidu.com/qianfan/ais/console/applicationConsole/application[API Keys page].
Exporting an environment variable is one way to set that configuration property:
[source,shell]
----
export SPRING_AI_QIANFAN_API_KEY=<INSERT KEY HERE>
----
=== Add Repositories and BOM
Spring AI artifacts are published in Spring Milestone and Snapshot repositories.
Refer to the xref:getting-started.adoc#repositories[Repositories] section to add these repositories to your build system.
To help with dependency management, Spring AI provides a BOM (bill of materials) to ensure that a consistent version of Spring AI is used throughout the entire project. Refer to the xref:getting-started.adoc#dependency-management[Dependency Management] section to add the Spring AI BOM to your build system.
== Auto-configuration
Spring AI provides Spring Boot auto-configuration for the Azure QianFan Embedding Client.
To enable it add the following dependency to your project's Maven `pom.xml` file:
[source, xml]
----
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-qianfan-spring-boot-starter</artifactId>
</dependency>
----
or to your Gradle `build.gradle` build file.
[source,groovy]
----
dependencies {
implementation 'org.springframework.ai:spring-ai-qianfan-spring-boot-starter'
}
----
TIP: Refer to the xref:getting-started.adoc#dependency-management[Dependency Management] section to add the Spring AI BOM to your build file.
=== Embedding Properties
==== Retry Properties
The prefix `spring.ai.retry` is used as the property prefix that lets you configure the retry mechanism for the QianFan Embedding client.
[cols="3,5,1"]
|====
| Property | Description | Default
| spring.ai.retry.max-attempts | Maximum number of retry attempts. | 10
| spring.ai.retry.backoff.initial-interval | Initial sleep duration for the exponential backoff policy. | 2 sec.
| spring.ai.retry.backoff.multiplier | Backoff interval multiplier. | 5
| spring.ai.retry.backoff.max-interval | Maximum backoff duration. | 3 min.
| spring.ai.retry.on-client-errors | If false, throw a NonTransientAiException, and do not attempt retry for `4xx` client error codes | false
| spring.ai.retry.exclude-on-http-codes | List of HTTP status codes that should not trigger a retry (e.g. to throw NonTransientAiException). | empty
| spring.ai.retry.on-http-codes | List of HTTP status codes that should trigger a retry (e.g. to throw TransientAiException). | empty
|====
==== Connection Properties
The prefix `spring.ai.qianfan` is used as the property prefix that lets you connect to QianFan.
[cols="3,5,1"]
|====
| Property | Description | Default
| spring.ai.qianfan.base-url | The URL to connect to | https://aip.baidubce.com/rpc/2.0/ai_custom
| spring.ai.qianfan.api-key | The API Key | -
| spring.ai.qianfan.secret-key | The Secret Key | -
|====
==== Configuration Properties
The prefix `spring.ai.qianfan.embedding` is property prefix that configures the `EmbeddingClient` implementation for QianFan.
[cols="3,5,1"]
|====
| Property | Description | Default
| spring.ai.qianfan.embedding.enabled | Enable QianFan embedding client. | true
| spring.ai.qianfan.embedding.base-url | Optional overrides the spring.ai.qianfan.base-url to provide embedding specific url | -
| spring.ai.qianfan.embedding.api-key | Optional overrides the spring.ai.qianfan.api-key to provide embedding specific api-key | -
| spring.ai.qianfan.embedding.secret-key | Optional overrides the spring.ai.qianfan.secret-key to provide embedding specific secret-key | -
| spring.ai.qianfan.embedding.options.model | The model to use | bge_large_zh
|====
NOTE: You can override the common `spring.ai.qianfan.base-url`, `spring.ai.qianfan.embedding.api-key` and `spring.ai.qianfan.embedding.secret-key` for the `ChatClient` and `EmbeddingClient` implementations.
The `spring.ai.qianfan.embedding.base-url`, `spring.ai.qianfan.embedding.api-key` and `spring.ai.qianfan.embedding.secret-key` properties if set take precedence over the common properties.
Similarly, the `spring.ai.qianfan.embedding.base-url`, `spring.ai.qianfan.embedding.api-key` and `spring.ai.qianfan.embedding.secret-key` properties if set take precedence over the common properties.
This is useful if you want to use different QianFan accounts for different models and different model endpoints.
TIP: All properties prefixed with `spring.ai.qianfan.embedding.options` can be overridden at runtime by adding a request specific <<embedding-options>> to the `EmbeddingRequest` call.
== Runtime Options [[embedding-options]]
The https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-qianfan/src/main/java/org/springframework/ai/qianfan/QianFanEmbeddingOptions.java[QianFanEmbeddingOptions.java] provides the QianFan configurations, such as the model to use and etc.
The default options can be configured using the `spring.ai.qianfan.embedding.options` properties as well.
At start-time use the `QianFanEmbeddingModel` constructor to set the default options used for all embedding requests.
At run-time you can override the default options, using a `QianFanEmbeddingOptions` instance as part of your `EmbeddingRequest`.
For example to override the default model name for a specific request:
[source,java]
----
EmbeddingResponse embeddingResponse = embeddingClient.call(
new EmbeddingRequest(List.of("Hello World", "World is big and salvation is near"),
QianFanEmbeddingOptions.builder()
.withModel("Different-Embedding-Model-Deployment-Name")
.build()));
----
== Sample Controller
This will create a `EmbeddingClient` implementation that you can inject into your class.
Here is an example of a simple `@Controller` class that uses the `EmbeddingClient` implementation.
[source,application.properties]
----
spring.ai.qianfan.api-key=YOUR_API_KEY
spring.ai.qianfan.secret-key=YOUR_SECRET_KEY
spring.ai.qianfan.embedding.options.model=tao_8k
----
[source,java]
----
@RestController
public class EmbeddingController {
private final EmbeddingClient embeddingClient;
@Autowired
public EmbeddingController(EmbeddingClient embeddingClient) {
this.embeddingClient = embeddingClient;
}
@GetMapping("/ai/embedding")
public Map embed(@RequestParam(value = "message", defaultValue = "Tell me a joke") String message) {
EmbeddingResponse embeddingResponse = this.embeddingClient.embedForResponse(List.of(message));
return Map.of("embedding", embeddingResponse);
}
}
----
== Manual Configuration
If you are not using Spring Boot, you can manually configure the QianFan Embedding Client.
For this add the `spring-ai-qianfan` dependency to your project's Maven `pom.xml` file:
[source, xml]
----
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-qianfan</artifactId>
</dependency>
----
or to your Gradle `build.gradle` build file.
[source,groovy]
----
dependencies {
implementation 'org.springframework.ai:spring-ai-qianfan'
}
----
TIP: Refer to the xref:getting-started.adoc#dependency-management[Dependency Management] section to add the Spring AI BOM to your build file.
NOTE: The `spring-ai-qianfan` dependency provides access also to the `QianFanChatModel`.
For more information about the `QianFanChatModel` refer to the link:../chat/qianfan-chat.html[QianFan Chat Client] section.
Next, create an `QianFanEmbeddingModel` instance and use it to compute the similarity between two input texts:
[source,java]
----
var qianFanApi = new QianFanApi(System.getenv("MINIMAX_API_KEY"), System.getenv("QIANFAN_SECRET_KEY"));
var embeddingClient = new QianFanEmbeddingModel(qianFanApi)
.withDefaultOptions(QianFanChatOptions.build()
.withModel("bge_large_en")
.build());
EmbeddingResponse embeddingResponse = embeddingClient
.embedForResponse(List.of("Hello World", "World is big and salvation is near"));
----
The `QianFanEmbeddingOptions` provides the configuration information for the embedding requests.
The options class offers a `builder()` for easy options creation.

View File

@@ -0,0 +1,119 @@
= QianFan Image Generation
Spring AI supports CogView, the Image generation model from QianFan.
== Prerequisites
You will need to create an API with QianFan to access QianFan language models.
Create an account at https://login.bce.baidu.com/new-reg[QianFan registration page] and generate the token on the https://console.bce.baidu.com/qianfan/ais/console/applicationConsole/application[API Keys page].
The Spring AI project defines a configuration property named `spring.ai.qianfan.api-key` and `spring.ai.qianfan.secret-key`.
you should set to the value of the `API Key` and `Secret Key` obtained from https://console.bce.baidu.com/qianfan/ais/console/applicationConsole/application[API Keys page].
Exporting an environment variable is one way to set that configuration property:
[source,shell]
----
export SPRING_AI_QIANFAN_API_KEY=<INSERT KEY HERE>
----
=== Add Repositories and BOM
Spring AI artifacts are published in Spring Milestone and Snapshot repositories.
Refer to the xref:getting-started.adoc#repositories[Repositories] section to add these repositories to your build system.
To help with dependency management, Spring AI provides a BOM (bill of materials) to ensure that a consistent version of Spring AI is used throughout the entire project. Refer to the xref:getting-started.adoc#dependency-management[Dependency Management] section to add the Spring AI BOM to your build system.
== Auto-configuration
Spring AI provides Spring Boot auto-configuration for the QianFan Chat Client.
To enable it add the following dependency to your project's Maven `pom.xml` file:
[source, xml]
----
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-qianfan-spring-boot-starter</artifactId>
</dependency>
----
or to your Gradle `build.gradle` build file.
[source,groovy]
----
dependencies {
implementation 'org.springframework.ai:spring-ai-qianfan-spring-boot-starter'
}
----
TIP: Refer to the xref:getting-started.adoc#dependency-management[Dependency Management] section to add the Spring AI BOM to your build file.
=== Image Generation Properties
The prefix `spring.ai.qianfan.image` is the property prefix that lets you configure the `ImageModel` implementation for QianFan.
[cols="3,5,1"]
|====
| Property | Description | Default
| spring.ai.qianfan.image.enabled | Enable QianFan image model. | true
| spring.ai.qianfan.image.base-url | Optional overrides the spring.ai.qianfan.base-url to provide chat specific url | -
| spring.ai.qianfan.image.api-key | Optional overrides the spring.ai.qianfan.api-key to provide chat specific api-key | -
| spring.ai.qianfan.image.secret-key | Optional overrides the spring.ai.qianfan.secret-key to provide chat specific secret-key | -
| spring.ai.qianfan.image.options.model | The model to use for image generation. | sd_xl
| spring.ai.qianfan.image.options.user | A unique identifier representing your end-user, which can help QianFan to monitor and detect abuse. | -
|====
==== Connection Properties
The prefix `spring.ai.qianfan` is used as the property prefix that lets you connect to QianFan.
[cols="3,5,1"]
|====
| Property | Description | Default
| spring.ai.qianfan.base-url | The URL to connect to | https://aip.baidubce.com/rpc/2.0/ai_custom
| spring.ai.qianfan.api-key | The API Key | -
| spring.ai.qianfan.secret-key | The Secret Key | -
|====
==== Configuration Properties
==== Retry Properties
The prefix `spring.ai.retry` is used as the property prefix that lets you configure the retry mechanism for the QianFan Image client.
[cols="3,5,1"]
|====
| Property | Description | Default
| spring.ai.retry.max-attempts | Maximum number of retry attempts. | 10
| spring.ai.retry.backoff.initial-interval | Initial sleep duration for the exponential backoff policy. | 2 sec.
| spring.ai.retry.backoff.multiplier | Backoff interval multiplier. | 5
| spring.ai.retry.backoff.max-interval | Maximum backoff duration. | 3 min.
| spring.ai.retry.on-client-errors | If false, throw a NonTransientAiException, and do not attempt retry for `4xx` client error codes | false
| spring.ai.retry.exclude-on-http-codes | List of HTTP status codes that should not trigger a retry (e.g. to throw NonTransientAiException). | empty
| spring.ai.retry.on-http-codes | List of HTTP status codes that should trigger a retry (e.g. to throw TransientAiException). | empty
|====
== Runtime Options [[image-options]]
The https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-qianfan/src/main/java/org/springframework/ai/qianfan/QianFanImageOptions.java[QianFanImageOptions.java] provides model configurations, such as the model to use, the quality, the size, etc.
On start-up, the default options can be configured with the `QianFanImageModel(QianFanImageApi qianFanImageApi)` constructor and the `withDefaultOptions(QianFanImageOptions defaultOptions)` method. Alternatively, use the `spring.ai.qianfan.image.options.*` properties described previously.
At runtime you can override the default options by adding new, request specific, options to the `ImagePrompt` call.
For example to override the QianFan specific options such as quality and the number of images to create, use the following code example:
[source,java]
----
ImageResponse response = qianFanImageModel.call(
new ImagePrompt("A light cream colored mini golden doodle",
QianFanImageOptions.builder()
.withN(4)
.withHeight(1024)
.withWidth(1024).build())
);
----
TIP: In addition to the model specific https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-qianfan/src/main/java/org/springframework/ai/qianfan/QianFanImageOptions.java[QianFanImageOptions] you can use a portable https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/image/ImageOptions.java[ImageOptions] instance, created with the https://github.com/spring-projects/spring-ai/blob/main/spring-ai-core/src/main/java/org/springframework/ai/image/ImageOptionsBuilder.java[ImageOptionsBuilder#builder()].

View File

@@ -326,6 +326,13 @@
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-qianfan</artifactId>
<version>${project.parent.version}</version>
<optional>true</optional>
</dependency>
<!-- Typesense vector store -->
<dependency>
<groupId>org.springframework.ai</groupId>

View File

@@ -0,0 +1,130 @@
/*
* Copyright 2023 - 2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.autoconfigure.qianfan;
import org.springframework.ai.autoconfigure.retry.SpringAiRetryAutoConfiguration;
import org.springframework.ai.model.function.FunctionCallbackContext;
import org.springframework.ai.qianfan.QianFanChatModel;
import org.springframework.ai.qianfan.QianFanEmbeddingModel;
import org.springframework.ai.qianfan.QianFanImageModel;
import org.springframework.ai.qianfan.api.QianFanApi;
import org.springframework.ai.qianfan.api.QianFanImageApi;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.retry.support.RetryTemplate;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
import org.springframework.web.client.ResponseErrorHandler;
import org.springframework.web.client.RestClient;
/**
* @author Geng Rong
*/
@AutoConfiguration(after = { RestClientAutoConfiguration.class, SpringAiRetryAutoConfiguration.class })
@ConditionalOnClass(QianFanApi.class)
@EnableConfigurationProperties({ QianFanConnectionProperties.class, QianFanChatProperties.class,
QianFanEmbeddingProperties.class, QianFanImageProperties.class })
public class QianFanAutoConfiguration {
@Bean
@ConditionalOnMissingBean
@ConditionalOnProperty(prefix = QianFanChatProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true",
matchIfMissing = true)
public QianFanChatModel qianFanChatModel(QianFanConnectionProperties commonProperties,
QianFanChatProperties chatProperties, RestClient.Builder restClientBuilder, RetryTemplate retryTemplate,
ResponseErrorHandler responseErrorHandler) {
var qianFanApi = qianFanApi(chatProperties.getBaseUrl(), commonProperties.getBaseUrl(),
chatProperties.getApiKey(), commonProperties.getApiKey(), chatProperties.getSecretKey(),
commonProperties.getSecretKey(), restClientBuilder, responseErrorHandler);
return new QianFanChatModel(qianFanApi, chatProperties.getOptions(), retryTemplate);
}
@Bean
@ConditionalOnMissingBean
@ConditionalOnProperty(prefix = QianFanEmbeddingProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true",
matchIfMissing = true)
public QianFanEmbeddingModel qianFanEmbeddingModel(QianFanConnectionProperties commonProperties,
QianFanEmbeddingProperties embeddingProperties, RestClient.Builder restClientBuilder,
RetryTemplate retryTemplate, ResponseErrorHandler responseErrorHandler) {
var qianFanApi = qianFanApi(embeddingProperties.getBaseUrl(), commonProperties.getBaseUrl(),
embeddingProperties.getApiKey(), commonProperties.getApiKey(), embeddingProperties.getSecretKey(),
commonProperties.getSecretKey(), restClientBuilder, responseErrorHandler);
return new QianFanEmbeddingModel(qianFanApi, embeddingProperties.getMetadataMode(),
embeddingProperties.getOptions(), retryTemplate);
}
@Bean
@ConditionalOnMissingBean
@ConditionalOnProperty(prefix = QianFanImageProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true",
matchIfMissing = true)
public QianFanImageModel qianFanImageModel(QianFanConnectionProperties commonProperties,
QianFanImageProperties imageProperties, RestClient.Builder restClientBuilder, RetryTemplate retryTemplate,
ResponseErrorHandler responseErrorHandler) {
String apiKey = StringUtils.hasText(imageProperties.getApiKey()) ? imageProperties.getApiKey()
: commonProperties.getApiKey();
String secretKey = StringUtils.hasText(imageProperties.getSecretKey()) ? imageProperties.getSecretKey()
: commonProperties.getSecretKey();
String baseUrl = StringUtils.hasText(imageProperties.getBaseUrl()) ? imageProperties.getBaseUrl()
: commonProperties.getBaseUrl();
Assert.hasText(apiKey, "QianFan API key must be set. Use the property: spring.ai.qianfan.api-key");
Assert.hasText(secretKey, "QianFan secret key must be set. Use the property: spring.ai.qianfan.secret-key");
Assert.hasText(baseUrl, "QianFan base URL must be set. Use the property: spring.ai.qianfan.base-url");
var qianFanImageApi = new QianFanImageApi(baseUrl, apiKey, secretKey, restClientBuilder, responseErrorHandler);
return new QianFanImageModel(qianFanImageApi, imageProperties.getOptions(), retryTemplate);
}
private QianFanApi qianFanApi(String baseUrl, String commonBaseUrl, String apiKey, String commonApiKey,
String secretKey, String commonSecretKey, RestClient.Builder restClientBuilder,
ResponseErrorHandler responseErrorHandler) {
String resolvedBaseUrl = StringUtils.hasText(baseUrl) ? baseUrl : commonBaseUrl;
Assert.hasText(resolvedBaseUrl, "QianFan base URL must be set");
String resolvedApiKey = StringUtils.hasText(apiKey) ? apiKey : commonApiKey;
Assert.hasText(resolvedApiKey, "QianFan API key must be set");
String resolvedSecretKey = StringUtils.hasText(secretKey) ? secretKey : commonSecretKey;
Assert.hasText(resolvedSecretKey, "QianFan Secret key must be set");
return new QianFanApi(resolvedBaseUrl, resolvedApiKey, resolvedSecretKey, restClientBuilder,
responseErrorHandler);
}
@Bean
@ConditionalOnMissingBean
public FunctionCallbackContext springAiFunctionManager(ApplicationContext context) {
FunctionCallbackContext manager = new FunctionCallbackContext();
manager.setApplicationContext(context);
return manager;
}
}

View File

@@ -0,0 +1,62 @@
/*
* Copyright 2023 - 2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.autoconfigure.qianfan;
import org.springframework.ai.qianfan.QianFanChatOptions;
import org.springframework.ai.qianfan.api.QianFanApi;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.NestedConfigurationProperty;
/**
* @author Geng Rong
*/
@ConfigurationProperties(QianFanChatProperties.CONFIG_PREFIX)
public class QianFanChatProperties extends QianFanParentProperties {
public static final String CONFIG_PREFIX = "spring.ai.qianfan.chat";
public static final String DEFAULT_CHAT_MODEL = QianFanApi.ChatModel.ERNIE_Speed_8K.value;
private static final Double DEFAULT_TEMPERATURE = 0.7;
/**
* Enable QianFan chat client.
*/
private boolean enabled = true;
@NestedConfigurationProperty
private QianFanChatOptions options = QianFanChatOptions.builder()
.withModel(DEFAULT_CHAT_MODEL)
.withTemperature(DEFAULT_TEMPERATURE.floatValue())
.build();
public QianFanChatOptions getOptions() {
return options;
}
public void setOptions(QianFanChatOptions options) {
this.options = options;
}
public boolean isEnabled() {
return this.enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
}

View File

@@ -0,0 +1,32 @@
/*
* Copyright 2023 - 2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.autoconfigure.qianfan;
import org.springframework.ai.qianfan.api.ApiUtils;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(QianFanConnectionProperties.CONFIG_PREFIX)
public class QianFanConnectionProperties extends QianFanParentProperties {
public static final String CONFIG_PREFIX = "spring.ai.qianfan";
public static final String DEFAULT_BASE_URL = ApiUtils.DEFAULT_BASE_URL;
public QianFanConnectionProperties() {
super.setBaseUrl(DEFAULT_BASE_URL);
}
}

View File

@@ -0,0 +1,68 @@
/*
* Copyright 2023 - 2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.autoconfigure.qianfan;
import org.springframework.ai.document.MetadataMode;
import org.springframework.ai.qianfan.QianFanEmbeddingOptions;
import org.springframework.ai.qianfan.api.QianFanApi;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.NestedConfigurationProperty;
/**
* @author Geng Rong
*/
@ConfigurationProperties(QianFanEmbeddingProperties.CONFIG_PREFIX)
public class QianFanEmbeddingProperties extends QianFanParentProperties {
public static final String CONFIG_PREFIX = "spring.ai.qianfan.embedding";
/**
* Enable QianFan embedding client.
*/
private boolean enabled = true;
private MetadataMode metadataMode = MetadataMode.EMBED;
@NestedConfigurationProperty
private QianFanEmbeddingOptions options = QianFanEmbeddingOptions.builder()
.withModel(QianFanApi.DEFAULT_EMBEDDING_MODEL)
.build();
public QianFanEmbeddingOptions getOptions() {
return this.options;
}
public void setOptions(QianFanEmbeddingOptions options) {
this.options = options;
}
public MetadataMode getMetadataMode() {
return this.metadataMode;
}
public void setMetadataMode(MetadataMode metadataMode) {
this.metadataMode = metadataMode;
}
public boolean isEnabled() {
return this.enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
}

View File

@@ -0,0 +1,62 @@
/*
* Copyright 2023 - 2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.autoconfigure.qianfan;
import org.springframework.ai.qianfan.QianFanImageOptions;
import org.springframework.ai.qianfan.api.QianFanImageApi;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.NestedConfigurationProperty;
/**
* QianFan Image autoconfiguration properties.
*
* @author Geng Rong
*/
@ConfigurationProperties(QianFanImageProperties.CONFIG_PREFIX)
public class QianFanImageProperties extends QianFanParentProperties {
public static final String CONFIG_PREFIX = "spring.ai.qianfan.image";
public static final String DEFAULT_IMAGE_MODEL = QianFanImageApi.ImageModel.Stable_Diffusion_XL.getValue();
/**
* Enable QianFan image model.
*/
private boolean enabled = true;
/**
* Options for QianFan Image API.
*/
@NestedConfigurationProperty
private QianFanImageOptions options = QianFanImageOptions.builder().withModel(DEFAULT_IMAGE_MODEL).build();
public QianFanImageOptions getOptions() {
return options;
}
public void setOptions(QianFanImageOptions options) {
this.options = options;
}
public boolean isEnabled() {
return this.enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
}

View File

@@ -0,0 +1,53 @@
/*
* Copyright 2023 - 2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.autoconfigure.qianfan;
/**
* @author Geng Rong
*/
class QianFanParentProperties {
private String apiKey;
private String secretKey;
private String baseUrl;
public String getApiKey() {
return apiKey;
}
public void setApiKey(String apiKey) {
this.apiKey = apiKey;
}
public String getSecretKey() {
return secretKey;
}
public void setSecretKey(String secretKey) {
this.secretKey = secretKey;
}
public String getBaseUrl() {
return baseUrl;
}
public void setBaseUrl(String baseUrl) {
this.baseUrl = baseUrl;
}
}

View File

@@ -39,3 +39,4 @@ org.springframework.ai.autoconfigure.chat.client.ChatClientAutoConfiguration
org.springframework.ai.autoconfigure.vectorstore.typesense.TypesenseVectorStoreAutoConfiguration
org.springframework.ai.autoconfigure.vectorstore.opensearch.OpenSearchVectorStoreAutoConfiguration
org.springframework.ai.autoconfigure.moonshot.MoonshotAutoConfiguration
org.springframework.ai.autoconfigure.qianfan.QianFanAutoConfiguration

View File

@@ -0,0 +1,112 @@
/*
* Copyright 2023 - 2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.autoconfigure.qianfan;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariables;
import org.springframework.ai.autoconfigure.retry.SpringAiRetryAutoConfiguration;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.embedding.EmbeddingResponse;
import org.springframework.ai.image.ImagePrompt;
import org.springframework.ai.image.ImageResponse;
import org.springframework.ai.qianfan.QianFanChatModel;
import org.springframework.ai.qianfan.QianFanEmbeddingModel;
import org.springframework.ai.qianfan.QianFanImageModel;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import reactor.core.publisher.Flux;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat;
/**
* @author Geng Rong
*/
@EnabledIfEnvironmentVariables(value = { @EnabledIfEnvironmentVariable(named = "QIANFAN_API_KEY", matches = ".+"),
@EnabledIfEnvironmentVariable(named = "QIANFAN_SECRET_KEY", matches = ".+") })
public class QianFanAutoConfigurationIT {
private static final Log logger = LogFactory.getLog(QianFanAutoConfigurationIT.class);
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
.withPropertyValues("spring.ai.qianfan.apiKey=" + System.getenv("QIANFAN_API_KEY"),
"spring.ai.qianfan.secretKey=" + System.getenv("QIANFAN_SECRET_KEY"))
.withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class,
RestClientAutoConfiguration.class, QianFanAutoConfiguration.class));
@Test
void generate() {
contextRunner.run(context -> {
QianFanChatModel client = context.getBean(QianFanChatModel.class);
String response = client.call("Hello");
assertThat(response).isNotEmpty();
logger.info("Response: " + response);
});
}
@Test
void generateStreaming() {
contextRunner.run(context -> {
QianFanChatModel client = context.getBean(QianFanChatModel.class);
Flux<ChatResponse> responseFlux = client.stream(new Prompt(new UserMessage("Hello")));
String response = Objects.requireNonNull(responseFlux.collectList().block())
.stream()
.map(chatResponse -> chatResponse.getResults().get(0).getOutput().getContent())
.collect(Collectors.joining());
assertThat(response).isNotEmpty();
logger.info("Response: " + response);
});
}
@Test
void embedding() {
contextRunner.run(context -> {
QianFanEmbeddingModel embeddingClient = context.getBean(QianFanEmbeddingModel.class);
EmbeddingResponse embeddingResponse = embeddingClient
.embedForResponse(List.of("Hello World", "World is big and salvation is near"));
assertThat(embeddingResponse.getResults()).hasSize(2);
assertThat(embeddingResponse.getResults().get(0).getOutput()).isNotEmpty();
assertThat(embeddingResponse.getResults().get(0).getIndex()).isEqualTo(0);
assertThat(embeddingResponse.getResults().get(1).getOutput()).isNotEmpty();
assertThat(embeddingResponse.getResults().get(1).getIndex()).isEqualTo(1);
assertThat(embeddingClient.dimensions()).isEqualTo(1024);
});
}
@Test
void generateImage() {
contextRunner.withPropertyValues("spring.ai.qianfan.image.options.size=1024x1024").run(context -> {
QianFanImageModel imageModel = context.getBean(QianFanImageModel.class);
ImageResponse imageResponse = imageModel.call(new ImagePrompt("forest"));
assertThat(imageResponse.getResults()).hasSize(1);
assertThat(imageResponse.getResult().getOutput().getUrl()).isNull();
assertThat(imageResponse.getResult().getOutput().getB64Json()).isNotEmpty();
logger.info("Generated image: " + imageResponse.getResult().getOutput().getB64Json());
});
}
}

View File

@@ -0,0 +1,438 @@
/*
* Copyright 2023 - 2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.autoconfigure.qianfan;
import org.junit.jupiter.api.Test;
import org.springframework.ai.autoconfigure.retry.SpringAiRetryAutoConfiguration;
import org.springframework.ai.qianfan.QianFanChatModel;
import org.springframework.ai.qianfan.QianFanEmbeddingModel;
import org.springframework.ai.qianfan.QianFanImageModel;
import org.springframework.ai.qianfan.api.QianFanApi;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration;
import org.springframework.boot.autoconfigure.web.reactive.function.client.WebClientAutoConfiguration;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Unit Tests for
* {@link org.springframework.ai.autoconfigure.qianfan.QianFanConnectionProperties},
* {@link org.springframework.ai.autoconfigure.qianfan.QianFanChatProperties} and
* {@link org.springframework.ai.autoconfigure.qianfan.QianFanEmbeddingProperties}.
*
* @author Geng Rong
*/
public class QianFanPropertiesTests {
@Test
public void chatProperties() {
new ApplicationContextRunner().withPropertyValues(
// @formatter:off
"spring.ai.qianfan.base-url=TEST_BASE_URL",
"spring.ai.qianfan.api-key=abc123",
"spring.ai.qianfan.secret-key=def123",
"spring.ai.qianfan.chat.options.model=MODEL_XYZ",
"spring.ai.qianfan.chat.options.temperature=0.55")
// @formatter:on
.withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class,
RestClientAutoConfiguration.class, QianFanAutoConfiguration.class))
.run(context -> {
var chatProperties = context.getBean(QianFanChatProperties.class);
var connectionProperties = context.getBean(QianFanConnectionProperties.class);
assertThat(connectionProperties.getApiKey()).isEqualTo("abc123");
assertThat(connectionProperties.getSecretKey()).isEqualTo("def123");
assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL");
assertThat(chatProperties.getApiKey()).isNull();
assertThat(chatProperties.getBaseUrl()).isNull();
assertThat(chatProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ");
assertThat(chatProperties.getOptions().getTemperature()).isEqualTo(0.55f);
});
}
@Test
public void chatOverrideConnectionProperties() {
new ApplicationContextRunner().withPropertyValues(
// @formatter:off
"spring.ai.qianfan.base-url=TEST_BASE_URL",
"spring.ai.qianfan.api-key=abc123",
"spring.ai.qianfan.secret-key=def123",
"spring.ai.qianfan.chat.base-url=TEST_BASE_URL2",
"spring.ai.qianfan.chat.api-key=456",
"spring.ai.qianfan.chat.secret-key=def456",
"spring.ai.qianfan.chat.options.model=MODEL_XYZ",
"spring.ai.qianfan.chat.options.temperature=0.55")
// @formatter:on
.withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class,
RestClientAutoConfiguration.class, QianFanAutoConfiguration.class))
.run(context -> {
var chatProperties = context.getBean(QianFanChatProperties.class);
var connectionProperties = context.getBean(QianFanConnectionProperties.class);
assertThat(connectionProperties.getApiKey()).isEqualTo("abc123");
assertThat(connectionProperties.getSecretKey()).isEqualTo("def123");
assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL");
assertThat(chatProperties.getApiKey()).isEqualTo("456");
assertThat(chatProperties.getSecretKey()).isEqualTo("def456");
assertThat(chatProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL2");
assertThat(chatProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ");
assertThat(chatProperties.getOptions().getTemperature()).isEqualTo(0.55f);
});
}
@Test
public void embeddingProperties() {
new ApplicationContextRunner().withPropertyValues(
// @formatter:off
"spring.ai.qianfan.base-url=TEST_BASE_URL",
"spring.ai.qianfan.api-key=abc123",
"spring.ai.qianfan.secret-key=def123",
"spring.ai.qianfan.embedding.options.model=MODEL_XYZ")
// @formatter:on
.withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class,
RestClientAutoConfiguration.class, QianFanAutoConfiguration.class))
.run(context -> {
var embeddingProperties = context.getBean(QianFanEmbeddingProperties.class);
var connectionProperties = context.getBean(QianFanConnectionProperties.class);
assertThat(connectionProperties.getApiKey()).isEqualTo("abc123");
assertThat(connectionProperties.getSecretKey()).isEqualTo("def123");
assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL");
assertThat(embeddingProperties.getApiKey()).isNull();
assertThat(embeddingProperties.getBaseUrl()).isNull();
assertThat(embeddingProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ");
});
}
@Test
public void embeddingOverrideConnectionProperties() {
new ApplicationContextRunner().withPropertyValues(
// @formatter:off
"spring.ai.qianfan.base-url=TEST_BASE_URL",
"spring.ai.qianfan.api-key=abc123",
"spring.ai.qianfan.secret-key=def123",
"spring.ai.qianfan.embedding.base-url=TEST_BASE_URL2",
"spring.ai.qianfan.embedding.api-key=456",
"spring.ai.qianfan.embedding.secret-key=def456",
"spring.ai.qianfan.embedding.options.model=MODEL_XYZ")
// @formatter:on
.withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class,
RestClientAutoConfiguration.class, QianFanAutoConfiguration.class))
.run(context -> {
var embeddingProperties = context.getBean(QianFanEmbeddingProperties.class);
var connectionProperties = context.getBean(QianFanConnectionProperties.class);
assertThat(connectionProperties.getApiKey()).isEqualTo("abc123");
assertThat(connectionProperties.getSecretKey()).isEqualTo("def123");
assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL");
assertThat(embeddingProperties.getApiKey()).isEqualTo("456");
assertThat(embeddingProperties.getSecretKey()).isEqualTo("def456");
assertThat(embeddingProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL2");
assertThat(embeddingProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ");
});
}
@Test
public void chatOptionsTest() {
new ApplicationContextRunner().withPropertyValues(
// @formatter:off
"spring.ai.qianfan.api-key=API_KEY",
"spring.ai.qianfan.secret-key=SECRET_KEY",
"spring.ai.qianfan.base-url=TEST_BASE_URL",
"spring.ai.qianfan.chat.options.model=MODEL_XYZ",
"spring.ai.qianfan.chat.options.frequencyPenalty=-1.5",
"spring.ai.qianfan.chat.options.logitBias.myTokenId=-5",
"spring.ai.qianfan.chat.options.maxTokens=123",
"spring.ai.qianfan.chat.options.presencePenalty=0",
"spring.ai.qianfan.chat.options.responseFormat.type=json",
"spring.ai.qianfan.chat.options.stop=boza,koza",
"spring.ai.qianfan.chat.options.temperature=0.55",
"spring.ai.qianfan.chat.options.topP=0.56"
)
// @formatter:on
.withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class,
RestClientAutoConfiguration.class, QianFanAutoConfiguration.class))
.run(context -> {
var chatProperties = context.getBean(QianFanChatProperties.class);
var connectionProperties = context.getBean(QianFanConnectionProperties.class);
var embeddingProperties = context.getBean(QianFanEmbeddingProperties.class);
assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL");
assertThat(connectionProperties.getApiKey()).isEqualTo("API_KEY");
assertThat(connectionProperties.getSecretKey()).isEqualTo("SECRET_KEY");
assertThat(embeddingProperties.getOptions().getModel()).isEqualTo("bge_large_zh");
assertThat(chatProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ");
assertThat(chatProperties.getOptions().getFrequencyPenalty()).isEqualTo(-1.5f);
assertThat(chatProperties.getOptions().getMaxTokens()).isEqualTo(123);
assertThat(chatProperties.getOptions().getPresencePenalty()).isEqualTo(0);
assertThat(chatProperties.getOptions().getResponseFormat())
.isEqualTo(new QianFanApi.ChatCompletionRequest.ResponseFormat("json"));
assertThat(chatProperties.getOptions().getStop()).contains("boza", "koza");
assertThat(chatProperties.getOptions().getTemperature()).isEqualTo(0.55f);
assertThat(chatProperties.getOptions().getTopP()).isEqualTo(0.56f);
});
}
@Test
public void embeddingOptionsTest() {
new ApplicationContextRunner().withPropertyValues(
// @formatter:off
"spring.ai.qianfan.api-key=API_KEY",
"spring.ai.qianfan.secret-key=SECRET_KEY",
"spring.ai.qianfan.base-url=TEST_BASE_URL",
"spring.ai.qianfan.embedding.options.model=MODEL_XYZ",
"spring.ai.qianfan.embedding.options.encodingFormat=MyEncodingFormat"
)
// @formatter:on
.withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class,
RestClientAutoConfiguration.class, QianFanAutoConfiguration.class))
.run(context -> {
var connectionProperties = context.getBean(QianFanConnectionProperties.class);
var embeddingProperties = context.getBean(QianFanEmbeddingProperties.class);
assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL");
assertThat(connectionProperties.getApiKey()).isEqualTo("API_KEY");
assertThat(connectionProperties.getSecretKey()).isEqualTo("SECRET_KEY");
assertThat(embeddingProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ");
});
}
@Test
void embeddingActivation() {
new ApplicationContextRunner()
.withPropertyValues("spring.ai.qianfan.api-key=API_KEY", "spring.ai.qianfan.secret-key=SECRET_KEY",
"spring.ai.qianfan.base-url=TEST_BASE_URL", "spring.ai.qianfan.embedding.enabled=false")
.withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class,
RestClientAutoConfiguration.class, QianFanAutoConfiguration.class))
.run(context -> {
assertThat(context.getBeansOfType(QianFanEmbeddingProperties.class)).isNotEmpty();
assertThat(context.getBeansOfType(QianFanEmbeddingModel.class)).isEmpty();
});
new ApplicationContextRunner()
.withPropertyValues("spring.ai.qianfan.api-key=API_KEY", "spring.ai.qianfan.secret-key=SECRET_KEY",
"spring.ai.qianfan.base-url=TEST_BASE_URL")
.withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class,
RestClientAutoConfiguration.class, QianFanAutoConfiguration.class))
.run(context -> {
assertThat(context.getBeansOfType(QianFanEmbeddingProperties.class)).isNotEmpty();
assertThat(context.getBeansOfType(QianFanEmbeddingModel.class)).isNotEmpty();
});
new ApplicationContextRunner()
.withPropertyValues("spring.ai.qianfan.api-key=API_KEY", "spring.ai.qianfan.secret-key=SECRET_KEY",
"spring.ai.qianfan.base-url=TEST_BASE_URL", "spring.ai.qianfan.embedding.enabled=true")
.withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class,
RestClientAutoConfiguration.class, QianFanAutoConfiguration.class))
.run(context -> {
assertThat(context.getBeansOfType(QianFanEmbeddingProperties.class)).isNotEmpty();
assertThat(context.getBeansOfType(QianFanEmbeddingModel.class)).isNotEmpty();
});
}
@Test
void chatActivation() {
new ApplicationContextRunner()
.withPropertyValues("spring.ai.qianfan.api-key=API_KEY", "spring.ai.qianfan.secret-key=SECRET_KEY",
"spring.ai.qianfan.base-url=TEST_BASE_URL", "spring.ai.qianfan.chat.enabled=false")
.withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class,
RestClientAutoConfiguration.class, QianFanAutoConfiguration.class))
.run(context -> {
assertThat(context.getBeansOfType(QianFanChatProperties.class)).isNotEmpty();
assertThat(context.getBeansOfType(QianFanChatModel.class)).isEmpty();
});
new ApplicationContextRunner()
.withPropertyValues("spring.ai.qianfan.api-key=API_KEY", "spring.ai.qianfan.secret-key=SECRET_KEY",
"spring.ai.qianfan.base-url=TEST_BASE_URL")
.withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class,
RestClientAutoConfiguration.class, QianFanAutoConfiguration.class))
.run(context -> {
assertThat(context.getBeansOfType(QianFanChatProperties.class)).isNotEmpty();
assertThat(context.getBeansOfType(QianFanChatModel.class)).isNotEmpty();
});
new ApplicationContextRunner()
.withPropertyValues("spring.ai.qianfan.api-key=API_KEY", "spring.ai.qianfan.secret-key=SECRET_KEY",
"spring.ai.qianfan.base-url=TEST_BASE_URL", "spring.ai.qianfan.chat.enabled=true")
.withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class,
RestClientAutoConfiguration.class, QianFanAutoConfiguration.class))
.run(context -> {
assertThat(context.getBeansOfType(QianFanChatProperties.class)).isNotEmpty();
assertThat(context.getBeansOfType(QianFanChatModel.class)).isNotEmpty();
});
}
@Test
public void imageProperties() {
new ApplicationContextRunner().withPropertyValues(
// @formatter:off
"spring.ai.qianfan.base-url=TEST_BASE_URL",
"spring.ai.qianfan.api-key=abc123",
"spring.ai.qianfan.secret-key=def123",
"spring.ai.qianfan.image.options.model=MODEL_XYZ",
"spring.ai.qianfan.image.options.n=3")
// @formatter:on
.withConfiguration(
AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, RestClientAutoConfiguration.class,
WebClientAutoConfiguration.class, QianFanAutoConfiguration.class))
.run(context -> {
var imageProperties = context.getBean(QianFanImageProperties.class);
var connectionProperties = context.getBean(QianFanConnectionProperties.class);
assertThat(connectionProperties.getApiKey()).isEqualTo("abc123");
assertThat(connectionProperties.getSecretKey()).isEqualTo("def123");
assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL");
assertThat(imageProperties.getApiKey()).isNull();
assertThat(imageProperties.getBaseUrl()).isNull();
assertThat(imageProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ");
assertThat(imageProperties.getOptions().getN()).isEqualTo(3);
});
}
@Test
public void imageOverrideConnectionProperties() {
new ApplicationContextRunner().withPropertyValues(
// @formatter:off
"spring.ai.qianfan.base-url=TEST_BASE_URL",
"spring.ai.qianfan.api-key=abc123",
"spring.ai.qianfan.secret-key=def123",
"spring.ai.qianfan.image.base-url=TEST_BASE_URL2",
"spring.ai.qianfan.image.api-key=456",
"spring.ai.qianfan.image.secret-key=def456",
"spring.ai.qianfan.image.options.model=MODEL_XYZ",
"spring.ai.qianfan.image.options.n=3")
// @formatter:on
.withConfiguration(
AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, RestClientAutoConfiguration.class,
WebClientAutoConfiguration.class, QianFanAutoConfiguration.class))
.run(context -> {
var imageProperties = context.getBean(QianFanImageProperties.class);
var connectionProperties = context.getBean(QianFanConnectionProperties.class);
assertThat(connectionProperties.getApiKey()).isEqualTo("abc123");
assertThat(connectionProperties.getSecretKey()).isEqualTo("def123");
assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL");
assertThat(imageProperties.getApiKey()).isEqualTo("456");
assertThat(imageProperties.getSecretKey()).isEqualTo("def456");
assertThat(imageProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL2");
assertThat(imageProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ");
assertThat(imageProperties.getOptions().getN()).isEqualTo(3);
});
}
@Test
public void imageOptionsTest() {
new ApplicationContextRunner().withPropertyValues(
// @formatter:off
"spring.ai.qianfan.api-key=API_KEY",
"spring.ai.qianfan.secret-key=SECRET_KEY",
"spring.ai.qianfan.base-url=TEST_BASE_URL",
"spring.ai.qianfan.image.options.n=3",
"spring.ai.qianfan.image.options.model=MODEL_XYZ",
"spring.ai.qianfan.image.options.size=1024x1024",
"spring.ai.qianfan.image.options.width=1024",
"spring.ai.qianfan.image.options.height=1024",
"spring.ai.qianfan.image.options.style=vivid",
"spring.ai.qianfan.image.options.user=userXYZ"
)
// @formatter:on
.withConfiguration(
AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, RestClientAutoConfiguration.class,
WebClientAutoConfiguration.class, QianFanAutoConfiguration.class))
.run(context -> {
var imageProperties = context.getBean(QianFanImageProperties.class);
var connectionProperties = context.getBean(QianFanConnectionProperties.class);
assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL");
assertThat(connectionProperties.getApiKey()).isEqualTo("API_KEY");
assertThat(connectionProperties.getSecretKey()).isEqualTo("SECRET_KEY");
assertThat(imageProperties.getOptions().getN()).isEqualTo(3);
assertThat(imageProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ");
assertThat(imageProperties.getOptions().getSize()).isEqualTo("1024x1024");
assertThat(imageProperties.getOptions().getWidth()).isEqualTo(1024);
assertThat(imageProperties.getOptions().getHeight()).isEqualTo(1024);
assertThat(imageProperties.getOptions().getStyle()).isEqualTo("vivid");
assertThat(imageProperties.getOptions().getUser()).isEqualTo("userXYZ");
});
}
@Test
void imageActivation() {
new ApplicationContextRunner()
.withPropertyValues("spring.ai.qianfan.api-key=API_KEY", "spring.ai.qianfan.secret-key=SECRET_KEY",
"spring.ai.qianfan.base-url=TEST_BASE_URL", "spring.ai.qianfan.image.enabled=false")
.withConfiguration(
AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, RestClientAutoConfiguration.class,
WebClientAutoConfiguration.class, QianFanAutoConfiguration.class))
.run(context -> {
assertThat(context.getBeansOfType(QianFanImageProperties.class)).isNotEmpty();
assertThat(context.getBeansOfType(QianFanImageModel.class)).isEmpty();
});
new ApplicationContextRunner()
.withPropertyValues("spring.ai.qianfan.api-key=API_KEY", "spring.ai.qianfan.secret-key=SECRET_KEY",
"spring.ai.qianfan.base-url=TEST_BASE_URL")
.withConfiguration(
AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, RestClientAutoConfiguration.class,
WebClientAutoConfiguration.class, QianFanAutoConfiguration.class))
.run(context -> {
assertThat(context.getBeansOfType(QianFanImageProperties.class)).isNotEmpty();
assertThat(context.getBeansOfType(QianFanImageModel.class)).isNotEmpty();
});
new ApplicationContextRunner()
.withPropertyValues("spring.ai.qianfan.api-key=API_KEY", "spring.ai.qianfan.secret-key=SECRET_KEY",
"spring.ai.qianfan.base-url=TEST_BASE_URL", "spring.ai.qianfan.image.enabled=true")
.withConfiguration(
AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, RestClientAutoConfiguration.class,
WebClientAutoConfiguration.class, QianFanAutoConfiguration.class))
.run(context -> {
assertThat(context.getBeansOfType(QianFanImageProperties.class)).isNotEmpty();
assertThat(context.getBeansOfType(QianFanImageModel.class)).isNotEmpty();
});
}
}

View File

@@ -217,7 +217,7 @@ public class ZhiPuAiPropertiesTests {
"spring.ai.zhipuai.chat.options.topP=0.56",
// "spring.ai.zhipuai.chat.options.toolChoice.functionName=toolChoiceFunctionName",
"spring.ai.zhipuai.chat.options.toolChoice=" + ModelOptionsUtils.toJsonString(ZhiPuAiApi.ChatCompletionRequest.ToolChoiceBuilder.FUNCTION("toolChoiceFunctionName")),
"spring.ai.zhipuai.chat.options.toolChoice=" + ModelOptionsUtils.toJsonString(ZhiPuAiApi.ChatCompletionRequest.ToolChoiceBuilder.function("toolChoiceFunctionName")),
"spring.ai.zhipuai.chat.options.tools[0].function.name=myFunction1",
"spring.ai.zhipuai.chat.options.tools[0].function.description=function description",

View File

@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai</artifactId>
<version>1.0.0-SNAPSHOT</version>
<relativePath>../../pom.xml</relativePath>
</parent>
<artifactId>spring-ai-qianfan-spring-boot-starter</artifactId>
<packaging>jar</packaging>
<name>Spring AI Starter - QianFan</name>
<description>Spring AI QianFan Auto Configuration</description>
<url>https://github.com/spring-projects/spring-ai</url>
<scm>
<url>https://github.com/spring-projects/spring-ai</url>
<connection>git://github.com/spring-projects/spring-ai.git</connection>
<developerConnection>git@github.com:spring-projects/spring-ai.git</developerConnection>
</scm>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-spring-boot-autoconfigure</artifactId>
<version>${project.parent.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-qianfan</artifactId>
<version>${project.parent.version}</version>
</dependency>
</dependencies>
</project>