Add integration for Mistral AI Chat and Embedding models

- Implement MistrealAiApi as a REST client for the Mistral REST API.
 - Add MistralAiChatClient implementing ChatClient and StreamingChatClient.
   Add MistralAiChatOptions implementing ChatOptions.
 - Add MistralAiEmbeddingClient impelementing EmbeddingClient.
   Add MistralAiEmbeddingOptions implementing EmbeddingOptions.
 - Add Unit and IT tests for api and clients.
 - Add mistral  auto-configuration and boot strarter
 - update auto-config property for Mistral AI embedding client
 - update auto-config property for Mistral AI embedding client

 Additional, review changes:
 - Add missing license headers.
 - Fix the intialization of defaul options.
 - Code re-formatting to improve the readabitliy.
 - Many improvements
 - Add missing sine annotations
 - Remove stream field from MistralAiChatOptions. This handled internally.
 - Add MistralAiApi ChatModel and EmbeddingModel enums.
 - Add MistralChatClientIT for testeing chat, stream , parsers...
 - Add MistralAiApiIT tests
 - Refactor and streamline the MistralAiAutoConfiguration.
 - Add MistralAiAutoConfigurationIT
This commit is contained in:
ricken07
2024-02-18 00:08:17 +01:00
committed by Christian Tzolov
parent 0d0b858d60
commit 30c3530561
29 changed files with 2345 additions and 0 deletions

View File

@@ -0,0 +1,70 @@
<?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>0.8.1-SNAPSHOT</version>
<relativePath>../../pom.xml</relativePath>
</parent>
<artifactId>spring-ai-mistral-ai</artifactId>
<packaging>jar</packaging>
<name>Spring AI Mistral AI</name>
<description>Mistral AI 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</groupId>
<artifactId>spring-web</artifactId>
<version>${spring-framework.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.retry</groupId>
<artifactId>spring-retry</artifactId>
<version>2.0.4</version>
</dependency>
<!-- Spring Framework -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-webflux</artifactId>
</dependency>
<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,174 @@
/*
* Copyright 2024-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.mistral;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import reactor.core.publisher.Flux;
import org.springframework.ai.chat.ChatClient;
import org.springframework.ai.chat.ChatResponse;
import org.springframework.ai.chat.Generation;
import org.springframework.ai.chat.StreamingChatClient;
import org.springframework.ai.chat.metadata.ChatGenerationMetadata;
import org.springframework.ai.chat.prompt.ChatOptions;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.mistral.api.MistralAiApi;
import org.springframework.ai.model.ModelOptionsUtils;
import org.springframework.retry.RetryCallback;
import org.springframework.retry.RetryContext;
import org.springframework.retry.RetryListener;
import org.springframework.retry.support.RetryTemplate;
import org.springframework.util.Assert;
/**
* @author Ricken Bazolo
* @since 0.8.1
*/
public class MistralAiChatClient implements ChatClient, StreamingChatClient {
private final Logger log = LoggerFactory.getLogger(getClass());
/**
* The default options used for the chat completion requests.
*/
private MistralAiChatOptions defaultOptions;
/**
* Low-level access to the OpenAI API.
*/
private final MistralAiApi mistralAiApi;
private final RetryTemplate retryTemplate = RetryTemplate.builder()
.maxAttempts(10)
.retryOn(MistralAiApi.MistralAiApiException.class)
.exponentialBackoff(Duration.ofMillis(2000), 5, Duration.ofMillis(3 * 60000))
.withListener(new RetryListener() {
public <T extends Object, E extends Throwable> void onError(RetryContext context,
RetryCallback<T, E> callback, Throwable throwable) {
log.warn("Retry error. Retry count:" + context.getRetryCount(), throwable);
};
})
.build();
public MistralAiChatClient(MistralAiApi mistralAiApi, MistralAiChatOptions options) {
Assert.notNull(mistralAiApi, "MistralAiApi must not be null");
Assert.notNull(options, "Options must not be null");
this.mistralAiApi = mistralAiApi;
this.defaultOptions = options;
}
public MistralAiChatClient(MistralAiApi mistralAiApi) {
this(mistralAiApi,
MistralAiChatOptions.builder()
.withTemperature(0.7f)
.withTopP(1f)
.withSafePrompt(false)
.withModel(MistralAiApi.ChatModel.TINY.getValue())
.build());
}
/**
* Accessible for testing.
*/
public MistralAiApi.ChatCompletionRequest createRequest(Prompt prompt, boolean stream) {
var chatCompletionMessages = prompt.getInstructions()
.stream()
.map(m -> new MistralAiApi.ChatCompletionMessage(m.getContent(),
MistralAiApi.ChatCompletionMessage.Role.valueOf(m.getMessageType().name())))
.toList();
var request = new MistralAiApi.ChatCompletionRequest(chatCompletionMessages, stream);
if (this.defaultOptions != null) {
request = ModelOptionsUtils.merge(request, this.defaultOptions, MistralAiApi.ChatCompletionRequest.class);
}
if (prompt.getOptions() != null) {
if (prompt.getOptions() instanceof ChatOptions runtimeOptions) {
var updatedRuntimeOptions = ModelOptionsUtils.copyToTarget(runtimeOptions, ChatOptions.class,
MistralAiChatOptions.class);
request = ModelOptionsUtils.merge(updatedRuntimeOptions, request,
MistralAiApi.ChatCompletionRequest.class);
}
else {
throw new IllegalArgumentException("Prompt options are not of type ChatOptions: "
+ prompt.getOptions().getClass().getSimpleName());
}
}
return request;
}
@Override
public ChatResponse call(Prompt prompt) {
return retryTemplate.execute(ctx -> {
var request = createRequest(prompt, false);
var completionEntity = this.mistralAiApi.chatCompletionEntity(request);
var chatCompletion = completionEntity.getBody();
if (chatCompletion == null) {
log.warn("No chat completion returned for prompt: {}", prompt);
return new ChatResponse(List.of());
}
List<Generation> generations = chatCompletion.choices()
.stream()
.map(choice -> new Generation(choice.message().content(),
Map.of("role", choice.message().role().name()))
.withGenerationMetadata(ChatGenerationMetadata.from(choice.finishReason().name(), null)))
.toList();
return new ChatResponse(generations);
});
}
@Override
public Flux<ChatResponse> stream(Prompt prompt) {
return retryTemplate.execute(ctx -> {
var request = createRequest(prompt, true);
var completionChunks = this.mistralAiApi.chatCompletionStream(request);
// For chunked responses, only the first chunk contains the choice role.
// The rest of the chunks with same ID share the same role.
ConcurrentHashMap<String, String> roleMap = new ConcurrentHashMap<>();
return completionChunks.map(chunk -> {
String chunkId = chunk.id();
List<Generation> generations = chunk.choices().stream().map(choice -> {
if (choice.delta().role() != null) {
roleMap.putIfAbsent(chunkId, choice.delta().role().name());
}
var generation = new Generation(choice.delta().content(), Map.of("role", roleMap.get(chunkId)));
if (choice.finishReason() != null) {
generation = generation
.withGenerationMetadata(ChatGenerationMetadata.from(choice.finishReason().name(), null));
}
return generation;
}).toList();
return new ChatResponse(generations);
});
});
}
}

View File

@@ -0,0 +1,177 @@
/*
* Copyright 2024-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.mistral;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.springframework.ai.chat.prompt.ChatOptions;
/**
* @author Ricken Bazolo
* @author Christian Tzolov
* @since 0.8.1
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public class MistralAiChatOptions implements ChatOptions {
/**
* ID of the model to use
*/
private @JsonProperty("model") String model;
/**
* What sampling temperature to use, between 0.0 and 1.0. 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;
/**
* 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;
/**
* The maximum number of tokens to generate in the completion. The token count of your
* prompt plus max_tokens cannot exceed the model's context length.
*/
private @JsonProperty("max_tokens") Integer maxTokens;
/**
* Whether to inject a safety prompt before all conversations.
*/
private @JsonProperty("safe_prompt") Boolean safePrompt;
/**
* The seed to use for random sampling. If set, different calls will generate
* deterministic results.
*/
private @JsonProperty("random_seed") Integer randomSeed;
public static Builder builder() {
return new Builder();
}
public static class Builder {
private final MistralAiChatOptions options = new MistralAiChatOptions();
public Builder withModel(String model) {
this.options.setModel(model);
return this;
}
public Builder withMaxToken(Integer maxTokens) {
this.options.setMaxTokens(maxTokens);
return this;
}
public Builder withSafePrompt(Boolean safePrompt) {
this.options.setSafePrompt(safePrompt);
return this;
}
public Builder withRandomSeed(Integer randomSeed) {
this.options.setRandomSeed(randomSeed);
return this;
}
public Builder withTemperature(Float temperature) {
this.options.setTemperature(temperature);
return this;
}
public Builder withTopP(Float topP) {
this.options.setTopP(topP);
return this;
}
public MistralAiChatOptions build() {
return this.options;
}
}
public String getModel() {
return this.model;
}
public void setModel(String model) {
this.model = model;
}
public Integer getMaxTokens() {
return this.maxTokens;
}
public void setMaxTokens(Integer maxTokens) {
this.maxTokens = maxTokens;
}
public Boolean getSafePrompt() {
return this.safePrompt;
}
public void setSafePrompt(Boolean safePrompt) {
this.safePrompt = safePrompt;
}
public Integer getRandomSeed() {
return this.randomSeed;
}
public void setRandomSeed(Integer randomSeed) {
this.randomSeed = randomSeed;
}
@Override
public Float getTemperature() {
return this.temperature;
}
@Override
public void setTemperature(Float temperature) {
this.temperature = temperature;
}
@Override
public Float getTopP() {
return this.topP;
}
@Override
public void setTopP(Float topP) {
this.topP = topP;
}
@Override
@JsonIgnore
public Integer getTopK() {
throw new UnsupportedOperationException("Unsupported option: 'TopK'");
}
@Override
@JsonIgnore
public void setTopK(Integer topK) {
throw new UnsupportedOperationException("Unsupported option: 'TopK'");
}
}

View File

@@ -0,0 +1,135 @@
/*
* Copyright 2024-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.mistral;
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.*;
import org.springframework.ai.mistral.api.MistralAiApi;
import org.springframework.ai.mistral.api.MistralAiApi.MistralAiApiException;
import org.springframework.ai.model.ModelOptionsUtils;
import org.springframework.retry.RetryCallback;
import org.springframework.retry.RetryContext;
import org.springframework.retry.RetryListener;
import org.springframework.retry.support.RetryTemplate;
import org.springframework.util.Assert;
import java.time.Duration;
import java.util.List;
/**
* @author Ricken Bazolo
* @since 0.8.1
*/
public class MistralAiEmbeddingClient extends AbstractEmbeddingClient {
private final Logger log = LoggerFactory.getLogger(getClass());
private final MistralAiEmbeddingOptions defaultOptions;
private final MetadataMode metadataMode;
private final MistralAiApi mistralAiApi;
private final RetryTemplate retryTemplate = RetryTemplate.builder()
.maxAttempts(10)
.retryOn(MistralAiApiException.class)
.exponentialBackoff(Duration.ofMillis(2000), 5, Duration.ofMillis(3 * 60000))
.withListener(new RetryListener() {
public <T extends Object, E extends Throwable> void onError(RetryContext context,
RetryCallback<T, E> callback, Throwable throwable) {
log.warn("Retry error. Retry count:" + context.getRetryCount(), throwable);
};
})
.build();
public MistralAiEmbeddingClient(MistralAiApi mistralAiApi) {
this(mistralAiApi, MetadataMode.EMBED);
}
public MistralAiEmbeddingClient(MistralAiApi mistralAiApi, MetadataMode metadataMode) {
this(mistralAiApi, metadataMode,
MistralAiEmbeddingOptions.builder().withModel(MistralAiApi.EmbeddingModel.EMBED.getValue()).build());
}
public MistralAiEmbeddingClient(MistralAiApi mistralAiApi, MistralAiEmbeddingOptions options) {
this(mistralAiApi, MetadataMode.EMBED, options);
}
public MistralAiEmbeddingClient(MistralAiApi mistralAiApi, MetadataMode metadataMode,
MistralAiEmbeddingOptions options) {
Assert.notNull(mistralAiApi, "MistralAiApi must not be null");
Assert.notNull(metadataMode, "metadataMode must not be null");
Assert.notNull(options, "options must not be null");
this.mistralAiApi = mistralAiApi;
this.metadataMode = metadataMode;
this.defaultOptions = options;
}
@Override
@SuppressWarnings("unchecked")
public EmbeddingResponse call(EmbeddingRequest request) {
return this.retryTemplate.execute(ctx -> {
var apiRequest = (this.defaultOptions != null)
? new MistralAiApi.EmbeddingRequest<>(request.getInstructions(), this.defaultOptions.getModel(),
this.defaultOptions.getEncodingFormat())
: new MistralAiApi.EmbeddingRequest<>(request.getInstructions(),
MistralAiApi.EmbeddingModel.EMBED.getValue());
if (request.getOptions() != null && !EmbeddingOptions.EMPTY.equals(request.getOptions())) {
apiRequest = ModelOptionsUtils.merge(request.getOptions(), apiRequest,
MistralAiApi.EmbeddingRequest.class);
}
var apiEmbeddingResponse = this.mistralAiApi.embeddings(apiRequest).getBody();
if (apiEmbeddingResponse == null) {
log.warn("No embeddings returned for request: {}", request);
return new EmbeddingResponse(List.of());
}
var metadata = generateResponseMetadata(apiEmbeddingResponse.model(), apiEmbeddingResponse.usage());
var embeddings = apiEmbeddingResponse.data()
.stream()
.map(e -> new Embedding(e.embedding(), e.index()))
.toList();
return new EmbeddingResponse(embeddings, metadata);
});
}
@Override
public List<Double> embed(Document document) {
Assert.notNull(document, "Document must not be null");
return this.embed(document.getFormattedContent(this.metadataMode));
}
private EmbeddingResponseMetadata generateResponseMetadata(String model, MistralAiApi.Usage usage) {
var 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,85 @@
/*
* Copyright 2024-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.mistral;
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;
/**
* @author Ricken Bazolo
* @since 0.8.1
*/
@JsonInclude(Include.NON_NULL)
public class MistralAiEmbeddingOptions implements EmbeddingOptions {
/**
* ID of the model to use.
*/
private @JsonProperty("model") String model;
/**
* The format to return the embeddings in. Can be either float or base64.
*/
private @JsonProperty("encoding_format") String encodingFormat;
public static Builder builder() {
return new Builder();
}
public String getModel() {
return this.model;
}
public void setModel(String model) {
this.model = model;
}
public String getEncodingFormat() {
return this.encodingFormat;
}
public void setEncodingFormat(String encodingFormat) {
this.encodingFormat = encodingFormat;
}
public static class Builder {
protected MistralAiEmbeddingOptions options;
public Builder() {
this.options = new MistralAiEmbeddingOptions();
}
public Builder withModel(String model) {
this.options.setModel(model);
return this;
}
public Builder withEncodingFormat(String encodingFormat) {
this.options.setEncodingFormat(encodingFormat);
return this;
}
public MistralAiEmbeddingOptions build() {
return this.options;
}
}
}

View File

@@ -0,0 +1,646 @@
/*
* Copyright 2024-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.mistral.api;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
import java.util.function.Predicate;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonInclude.Include;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.client.ClientHttpResponse;
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;
/**
* Implementation of the MistralAI Embedding API:
* <a href="https://docs.mistral.ai/api/#operation/createEmbedding">...</a> and Chat
* Completion API:
* <a href="https://docs.mistral.ai/api/#operation/createChatCompletion">...</a>
*
* @author Ricken Bazolo
* @author Christian Tzolov
* @since 0.8.1
*/
public class MistralAiApi {
private static final String DEFAULT_BASE_URL = "https://api.mistral.ai";
private static final Predicate<String> SSE_DONE_PREDICATE = "[DONE]"::equals;
private final RestClient restClient;
private WebClient webClient;
private final ObjectMapper objectMapper;
/**
* Create a new client api with DEFAULT_BASE_URL
* @param mistralAiApiKey Mistral api Key.
*/
public MistralAiApi(String mistralAiApiKey) {
this(DEFAULT_BASE_URL, mistralAiApiKey);
}
/**
* Create a new client api.
* @param baseUrl api base URL.
* @param mistralAiApiKey Mistral api Key.
*/
public MistralAiApi(String baseUrl, String mistralAiApiKey) {
this(baseUrl, mistralAiApiKey, RestClient.builder());
}
/**
* Create a new client api.
* @param baseUrl api base URL.
* @param mistralAiApiKey Mistral api Key.
* @param restClientBuilder RestClient builder.
*/
public MistralAiApi(String baseUrl, String mistralAiApiKey, RestClient.Builder restClientBuilder) {
this.objectMapper = new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
Consumer<HttpHeaders> jsonContentHeaders = headers -> {
headers.setBearerAuth(mistralAiApiKey);
headers.setContentType(MediaType.APPLICATION_JSON);
};
var responseErrorHandler = new ResponseErrorHandler() {
@Override
public boolean hasError(ClientHttpResponse response) throws IOException {
return response.getStatusCode().isError();
}
@Override
public void handleError(ClientHttpResponse response) throws IOException {
if (response.getStatusCode().isError()) {
if (response.getStatusCode().is4xxClientError()) {
throw new MistralAiApiClientErrorException(String.format("%s - %s",
response.getStatusCode().value(),
MistralAiApi.this.objectMapper.readValue(response.getBody(), ResponseError.class)));
}
throw new MistralAiApiException(String.format("%s - %s", response.getStatusCode().value(),
MistralAiApi.this.objectMapper.readValue(response.getBody(), ResponseError.class)));
}
}
};
this.restClient = restClientBuilder.baseUrl(baseUrl)
.defaultHeaders(jsonContentHeaders)
.defaultStatusHandler(responseErrorHandler)
.build();
this.webClient = WebClient.builder().baseUrl(baseUrl).defaultHeaders(jsonContentHeaders).build();
}
public static class MistralAiApiException extends RuntimeException {
public MistralAiApiException(String message) {
super(message);
}
public MistralAiApiException(String message, Throwable t) {
super(message, t);
}
}
/**
* Thrown on 4xx client errors, such as 401 - Incorrect API key provided, 401 - You
* must be a member of an organization to use the API, 429 - Rate limit reached for
* requests, 429 - You exceeded your current quota , please check your plan and
* billing details.
*/
public static class MistralAiApiClientErrorException extends RuntimeException {
public MistralAiApiClientErrorException(String message) {
super(message);
}
}
/**
* API error response.
*
* @param error Error details.
*/
@JsonInclude(Include.NON_NULL)
public record ResponseError(@JsonProperty("error") Error error) {
/**
* Error details.
*
* @param message Error message.
* @param type Error type.
* @param param Error parameter.
* @param code Error code.
*/
@JsonInclude(Include.NON_NULL)
public record Error(
// @formatter:off
@JsonProperty("message") String message,
@JsonProperty("type") String type,
@JsonProperty("param") String param,
@JsonProperty("code") String code) {
// @formatter:on
}
}
/**
* Usage statistics.
*
* @param promptTokens Number of tokens in the prompt.
* @param totalTokens Total number of tokens used in the request (prompt +
* completion).
* @param completionTokens Number of tokens in the generated completion. Only
* applicable for completion requests.
*/
@JsonInclude(Include.NON_NULL)
public record Usage(
// @formatter:off
@JsonProperty("prompt_tokens") Integer promptTokens,
@JsonProperty("total_tokens") Integer totalTokens,
@JsonProperty("completion_tokens") Integer completionTokens) {
// @formatter:on
}
/**
* 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");
}
}
/**
* Creates an embedding vector representing the input text.
*
* @param input Input text to embed, encoded as a string or array of tokens
* @param model ID of the model to use.
* @param encodingFormat The format to return the embeddings in. Can be either float
* or base64.
*/
@JsonInclude(Include.NON_NULL)
public record EmbeddingRequest<T>(
// @formatter:off
@JsonProperty("input") T input,
@JsonProperty("model") String model,
@JsonProperty("encoding_format") String encodingFormat) {
// @formatter:on
/**
* Create an embedding request with the given input, model and encoding format set
* to float.
* @param input Input text to embed.
* @param model ID of the model to use.
*/
public EmbeddingRequest(T input, String model) {
this(input, model, "float");
}
/**
* Create an embedding request with the given input. Encoding format is set to
* float and user is null and the model is set to 'mistral-embed'.
* @param input Input text to embed.
*/
public EmbeddingRequest(T input) {
this(input, EmbeddingModel.EMBED.getValue());
}
}
/**
* List of multiple embedding responses.
*
* @param <T> Type of the entities in the data list.
* @param object Must have value "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<T>(
// @formatter:off
@JsonProperty("object") String object,
@JsonProperty("data") List<T> data,
@JsonProperty("model") String model,
@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}.
* @param <T> Type of the entity in the data list. Can be a {@link String} or
* {@link List} of tokens (e.g. Integers). For embedding multiple inputs in a single
* request, You can pass a {@link List} of {@link String} or {@link List} of
* {@link List} of tokens. For example:
*
* <pre>{@code List.of("text1", "text2", "text3") or List.of(List.of(1, 2, 3), List.of(3, 4, 5))} </pre>
*/
public <T> ResponseEntity<EmbeddingList<Embedding>> embeddings(EmbeddingRequest<T> 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.input(), "The input can not be null.");
Assert.isTrue(embeddingRequest.input() instanceof String || embeddingRequest.input() instanceof List,
"The input must be either a String, or a List of Strings or List of List of integers.");
// The input must not an empty string, and any array must be 1024 dimensions or
// less.
if (embeddingRequest.input() instanceof List list) {
Assert.isTrue(!CollectionUtils.isEmpty(list), "The input list can not be empty.");
Assert.isTrue(list.size() <= 1024, "The list must be 1024 dimensions or less");
Assert.isTrue(
list.get(0) instanceof String || list.get(0) instanceof Integer || list.get(0) instanceof List,
"The input must be either a String, or a List of Strings or list of list of integers.");
}
return this.restClient.post()
.uri("/v1/embeddings")
.body(embeddingRequest)
.retrieve()
.toEntity(new ParameterizedTypeReference<>() {
});
}
/**
* Creates a model request for chat conversation.
*
* @param model ID of the model to use.
* @param messages The prompt(s) to generate completions for, encoded as a list of
* dict with role and content. The first prompt role should be user or system.
* @param temperature What sampling temperature to use, between 0.0 and 1.0. 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 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.
* @param maxTokens The maximum number of tokens to generate in the completion. The
* token count of your prompt plus max_tokens cannot exceed the model's context
* length.
* @param stream Whether to stream back partial progress. If set, tokens will be sent
* as data-only server-sent events as they become available, with the stream
* terminated by a data: [DONE] message. Otherwise, the server will hold the request
* open until the timeout or until completion, with the response containing the full
* result as JSON.
* @param safePrompt Whether to inject a safety prompt before all conversations.
* @param randomSeed The seed to use for random sampling. If set, different calls will
* generate deterministic results.
*/
@JsonInclude(Include.NON_NULL)
public record ChatCompletionRequest(
// @formatter:off
@JsonProperty("model") String model,
@JsonProperty("messages") List<ChatCompletionMessage> messages,
@JsonProperty("temperature") Float temperature,
@JsonProperty("top_p") Float topP,
@JsonProperty("max_tokens") Integer maxTokens,
@JsonProperty("stream") Boolean stream,
@JsonProperty("safe_prompt") Boolean safePrompt,
@JsonProperty("random_seed") Integer randomSeed) {
// @formatter:on
/**
* Shortcut constructor for a chat completion request with the given messages and
* model.
* @param messages The prompt(s) to generate completions for, encoded as a list of
* dict with role and content. The first prompt role should be user or system.
* @param model ID of the model to use.
*/
public ChatCompletionRequest(List<ChatCompletionMessage> messages, String model) {
this(model, messages, 0.7f, 1f, null, false, false, null);
}
/**
* Shortcut constructor for a chat completion request with the given messages,
* model and temperature.
* @param messages The prompt(s) to generate completions for, encoded as a list of
* dict with role and content. The first prompt role should be user or system.
* @param model ID of the model to use.
* @param temperature What sampling temperature to use, between 0.0 and 1.0.
* @param stream Whether to stream back partial progress. If set, tokens will be
* sent
*/
public ChatCompletionRequest(List<ChatCompletionMessage> messages, String model, Float temperature,
boolean stream) {
this(model, messages, temperature, 1f, null, stream, false, null);
}
/**
* Shortcut constructor for a chat completion request with the given messages,
* model and temperature.
* @param messages The prompt(s) to generate completions for, encoded as a list of
* dict with role and content. The first prompt role should be user or system.
* @param model ID of the model to use.
* @param temperature What sampling temperature to use, between 0.0 and 1.0.
*
*/
public ChatCompletionRequest(List<ChatCompletionMessage> messages, String model, Float temperature) {
this(model, messages, temperature, 1f, null, false, false, null);
}
/**
* Shortcut constructor for a chat completion request with the given messages and
* stream.
*/
public ChatCompletionRequest(List<ChatCompletionMessage> messages, Boolean stream) {
this(null, messages, 0.7f, 1f, null, stream, false, null);
}
}
/**
* Message comprising the conversation.
*
* @param content The contents of the message.
* @param role The role of the messages author. Could be one of the {@link Role}
* types.
*/
@JsonInclude(Include.NON_NULL)
public record ChatCompletionMessage(
// @formatter:off
@JsonProperty("content") String content,
@JsonProperty("role") Role role) {
// @formatter:on
/**
* The role of the author of this message.
*
* NOTE: Mistral expects the system message to be before the user message or will
* fail with 400 error.
*/
public enum Role {
// @formatter:off
@JsonProperty("system") SYSTEM,
@JsonProperty("user") USER,
@JsonProperty("assistant") ASSISTANT
// @formatter:on
}
}
/**
* The reason the model stopped generating tokens.
*/
public enum ChatCompletionFinishReason {
// @formatter:off
/**
* The model hit a natural stop point or a provided stop sequence.
*/
@JsonProperty("stop") STOP,
/**
* The maximum number of tokens specified in the request was reached.
*/
@JsonProperty("length") LENGTH,
/**
* The content was omitted due to a flag from our content filters.
*/
@JsonProperty("model_length") MODEL_LENGTH
// @formatter:on
}
/**
* Represents a chat completion response returned by model, based on the provided
* input.
*
* @param id A unique identifier for the chat completion.
* @param object The object type, which is always chat.completion.
* @param created The Unix timestamp (in seconds) of when the chat completion was
* created.
* @param model The model used for the chat completion.
* @param choices A list of chat completion choices.
* @param usage Usage statistics for the completion request.
*/
@JsonInclude(Include.NON_NULL)
public record ChatCompletion(
// @formatter:off
@JsonProperty("id") String id,
@JsonProperty("object") String object,
@JsonProperty("created") Long created,
@JsonProperty("model") String model,
@JsonProperty("choices") List<Choice> choices,
@JsonProperty("usage") Usage usage) {
// @formatter:on
/**
* Chat completion choice.
*
* @param index The index of the choice in the list of choices.
* @param message A chat completion message generated by the model.
* @param finishReason The reason the model stopped generating tokens.
*/
@JsonInclude(Include.NON_NULL)
public record Choice(
// @formatter:off
@JsonProperty("index") Integer index,
@JsonProperty("message") ChatCompletionMessage message,
@JsonProperty("finish_reason") ChatCompletionFinishReason finishReason) {
// @formatter:on
}
}
/**
* 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 model The model used for the chat completion.
* @param choices A list of chat completion choices. Can be more than one if n is
* greater than 1.
*/
@JsonInclude(Include.NON_NULL)
public record ChatCompletionChunk(
// @formatter:off
@JsonProperty("id") String id,
@JsonProperty("object") String object,
@JsonProperty("created") Long created,
@JsonProperty("model") String model,
@JsonProperty("choices") List<ChunkChoice> choices) {
// @formatter:on
/**
* Chat completion choice.
*
* @param index The index of the choice in the list of choices.
* @param delta A chat completion delta generated by streamed model responses.
* @param finishReason The reason the model stopped generating tokens.
*/
@JsonInclude(Include.NON_NULL)
public record ChunkChoice(
// @formatter:off
@JsonProperty("index") Integer index,
@JsonProperty("delta") ChatCompletionMessage delta,
@JsonProperty("finish_reason") ChatCompletionFinishReason finishReason) {
// @formatter:on
}
}
/**
* List of well-known Mistral chat models.
* https://docs.mistral.ai/platform/endpoints/#mistral-ai-generative-models
*/
public enum ChatModel {
// @formatter:off
@JsonProperty("mistral-tiny") TINY("mistral-tiny"),
@JsonProperty("mistral-small") SMALL("mistral-small"),
@JsonProperty("mistral-medium") MEDIUM("mistral-medium"),
@JsonProperty("mistral-large") LARGE("mistral-large"),
@JsonProperty("mistral-xlarge") XLARGE("mistral-xlarge");
// @formatter:on
private final String value;
ChatModel(String value) {
this.value = value;
}
public String getValue() {
return this.value;
}
}
/**
* List of well-known Mistral embedding models.
* https://docs.mistral.ai/platform/endpoints/#mistral-ai-embedding-model
*/
public enum EmbeddingModel {
// @formatter:off
@JsonProperty("mistral-embed") EMBED("mistral-embed");
// @formatter:on
private final String value;
EmbeddingModel(String value) {
this.value = value;
}
public String getValue() {
return this.value;
}
}
/**
* 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/chat/completions")
.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/chat/completions")
.body(Mono.just(chatRequest), ChatCompletionRequest.class)
.retrieve()
.bodyToFlux(String.class)
.takeUntil(SSE_DONE_PREDICATE)
.filter(SSE_DONE_PREDICATE.negate())
.map(content -> parseJson(content, ChatCompletionChunk.class));
}
public static Map<String, Object> parseJson(String jsonSchema) {
try {
return new ObjectMapper().readValue(jsonSchema, new TypeReference<Map<String, Object>>() {
});
}
catch (Exception e) {
throw new MistralAiApiException("Failed to parse schema: " + jsonSchema, e);
}
}
private <T> T parseJson(String json, Class<T> type) {
try {
return this.objectMapper.readValue(json, type);
}
catch (Exception e) {
throw new MistralAiApiException("Failed to parse schema: " + json, e);
}
}
}

View File

@@ -0,0 +1,50 @@
/*
* Copyright 2024-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.mistral;
import org.springframework.ai.embedding.EmbeddingClient;
import org.springframework.ai.mistral.api.MistralAiApi;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.util.StringUtils;
@SpringBootConfiguration
public class MistralAiTestConfiguration {
@Bean
public MistralAiApi mistralAiApi() {
var apiKey = System.getenv("MISTRAL_AI_API_KEY");
if (!StringUtils.hasText(apiKey)) {
throw new IllegalArgumentException(
"Missing MISTRAL_AI_API_KEY environment variable. Please set it to your Mistral AI API key.");
}
return new MistralAiApi(apiKey);
}
@Bean
public EmbeddingClient mistralAiEmbeddingClient(MistralAiApi api) {
return new MistralAiEmbeddingClient(api,
MistralAiEmbeddingOptions.builder().withModel(MistralAiApi.EmbeddingModel.EMBED.getValue()).build());
}
@Bean
public MistralAiChatClient mistralAiChatClient(MistralAiApi mistralAiApi) {
return new MistralAiChatClient(mistralAiApi,
MistralAiChatOptions.builder().withModel(MistralAiApi.ChatModel.SMALL.getValue()).build());
}
}

View File

@@ -0,0 +1,186 @@
/*
* Copyright 2024-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.mistral.chat;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.ChatClient;
import org.springframework.ai.chat.ChatResponse;
import org.springframework.ai.chat.Generation;
import org.springframework.ai.chat.StreamingChatClient;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.chat.prompt.PromptTemplate;
import org.springframework.ai.chat.prompt.SystemPromptTemplate;
import org.springframework.ai.mistral.MistralAiTestConfiguration;
import org.springframework.ai.parser.BeanOutputParser;
import org.springframework.ai.parser.ListOutputParser;
import org.springframework.ai.parser.MapOutputParser;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.core.convert.support.DefaultConversionService;
import org.springframework.core.io.Resource;
import static org.assertj.core.api.Assertions.assertThat;
/**
* @author Christian Tzolov
* @since 0.8.1
*/
@SpringBootTest(classes = MistralAiTestConfiguration.class)
@EnabledIfEnvironmentVariable(named = "MISTRAL_AI_API_KEY", matches = ".+")
class MistralAiChatClientIT {
private static final Logger logger = LoggerFactory.getLogger(MistralAiChatClientIT.class);
@Autowired
protected ChatClient chatClient;
@Autowired
protected StreamingChatClient streamingChatClient;
@Value("classpath:/prompts/system-message.st")
private Resource systemResource;
@Value("classpath:/prompts/eval/qa-evaluator-accurate-answer.st")
protected Resource qaEvaluatorAccurateAnswerResource;
@Value("classpath:/prompts/eval/qa-evaluator-not-related-message.st")
protected Resource qaEvaluatorNotRelatedResource;
@Value("classpath:/prompts/eval/qa-evaluator-fact-based-answer.st")
protected Resource qaEvalutaorFactBasedAnswerResource;
@Value("classpath:/prompts/eval/user-evaluator-message.st")
protected Resource userEvaluatorResource;
@Test
void roleTest() {
UserMessage userMessage = new UserMessage(
"Tell me about 3 famous pirates from the Golden Age of Piracy and why they did.");
SystemPromptTemplate systemPromptTemplate = new SystemPromptTemplate(systemResource);
Message systemMessage = systemPromptTemplate.createMessage(Map.of("name", "Bob", "voice", "pirate"));
// NOTE: Mistral expects the system message to be before the user message or will
// fail with 400 error.
Prompt prompt = new Prompt(List.of(systemMessage, userMessage));
ChatResponse response = chatClient.call(prompt);
assertThat(response.getResults()).hasSize(1);
assertThat(response.getResults().get(0).getOutput().getContent()).contains("Blackbeard");
}
@Test
void outputParser() {
DefaultConversionService conversionService = new DefaultConversionService();
ListOutputParser outputParser = new ListOutputParser(conversionService);
String format = outputParser.getFormat();
String template = """
List five {subject}
{format}
""";
PromptTemplate promptTemplate = new PromptTemplate(template,
Map.of("subject", "ice cream flavors", "format", format));
Prompt prompt = new Prompt(promptTemplate.createMessage());
Generation generation = this.chatClient.call(prompt).getResult();
List<String> list = outputParser.parse(generation.getOutput().getContent());
assertThat(list).hasSize(5);
}
@Test
void mapOutputParser() {
MapOutputParser outputParser = new MapOutputParser();
String format = outputParser.getFormat();
String template = """
Provide me a List of {subject}
{format}
""";
PromptTemplate promptTemplate = new PromptTemplate(template,
Map.of("subject", "an array of numbers from 1 to 9 under they key name 'numbers'", "format", format));
Prompt prompt = new Prompt(promptTemplate.createMessage());
Generation generation = chatClient.call(prompt).getResult();
Map<String, Object> result = outputParser.parse(generation.getOutput().getContent());
assertThat(result.get("numbers")).isEqualTo(Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9));
}
record ActorsFilmsRecord(String actor, List<String> movies) {
}
@Test
void beanOutputParserRecords() {
BeanOutputParser<ActorsFilmsRecord> outputParser = new BeanOutputParser<>(ActorsFilmsRecord.class);
String format = outputParser.getFormat();
String template = """
Generate the filmography of 5 movies for Tom Hanks.
{format}
""";
PromptTemplate promptTemplate = new PromptTemplate(template, Map.of("format", format));
Prompt prompt = new Prompt(promptTemplate.createMessage());
Generation generation = chatClient.call(prompt).getResult();
ActorsFilmsRecord actorsFilms = outputParser.parse(generation.getOutput().getContent());
logger.info("" + actorsFilms);
assertThat(actorsFilms.actor()).isEqualTo("Tom Hanks");
assertThat(actorsFilms.movies()).hasSize(5);
}
@Test
void beanStreamOutputParserRecords() {
BeanOutputParser<ActorsFilmsRecord> outputParser = new BeanOutputParser<>(ActorsFilmsRecord.class);
String format = outputParser.getFormat();
String template = """
Generate the filmography of 5 movies for Tom Hanks.
{format}
""";
PromptTemplate promptTemplate = new PromptTemplate(template, Map.of("format", format));
Prompt prompt = new Prompt(promptTemplate.createMessage());
String generationTextFromStream = streamingChatClient.stream(prompt)
.collectList()
.block()
.stream()
.map(ChatResponse::getResults)
.flatMap(List::stream)
.map(Generation::getOutput)
.map(AssistantMessage::getContent)
.collect(Collectors.joining());
ActorsFilmsRecord actorsFilms = outputParser.parse(generationTextFromStream);
logger.info("" + actorsFilms);
assertThat(actorsFilms.actor()).isEqualTo("Tom Hanks");
assertThat(actorsFilms.movies()).hasSize(5);
}
}

View File

@@ -0,0 +1,65 @@
/*
* Copyright 2024-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.mistral.chat;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.mistral.MistralAiChatClient;
import org.springframework.ai.mistral.MistralAiChatOptions;
import org.springframework.ai.mistral.api.MistralAiApi;
import org.springframework.boot.test.context.SpringBootTest;
import static org.assertj.core.api.Assertions.assertThat;
/**
* @author Ricken Bazolo
* @since 0.8.1
*/
@SpringBootTest
@EnabledIfEnvironmentVariable(named = "MISTRAL_AI_API_KEY", matches = ".+")
public class MistralChatCompletionRequestTest {
MistralAiChatClient chatClient = new MistralAiChatClient(new MistralAiApi("test"));
@Test
void chatCompletionDefaultRequestTest() {
var request = chatClient.createRequest(new Prompt("test content"), false);
assertThat(request.messages()).hasSize(1);
assertThat(request.topP()).isEqualTo(1);
assertThat(request.temperature()).isEqualTo(0.7f);
assertThat(request.safePrompt()).isFalse();
assertThat(request.maxTokens()).isNull();
}
@Test
void chatCompletionRequestWithOptionsTest() {
var options = MistralAiChatOptions.builder().withTemperature(0.5f).withTopP(0.8f).build();
var request = chatClient.createRequest(new Prompt("test content", options), false);
assertThat(request.messages().size()).isEqualTo(1);
assertThat(request.topP()).isEqualTo(0.8f);
assertThat(request.temperature()).isEqualTo(0.5f);
assertThat(request.stream()).isTrue();
}
}

View File

@@ -0,0 +1,93 @@
/*
* Copyright 2024-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.mistral.chat.api;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
import reactor.core.publisher.Flux;
import org.springframework.ai.mistral.api.MistralAiApi;
import org.springframework.ai.mistral.api.MistralAiApi.ChatCompletionMessage.Role;
import org.springframework.ai.mistral.api.MistralAiApi.ChatCompletionRequest;
import org.springframework.ai.mistral.api.MistralAiApi.Embedding;
import org.springframework.ai.mistral.api.MistralAiApi.EmbeddingList;
import org.springframework.ai.mistral.api.MistralAiApi.ChatCompletion;
import org.springframework.ai.mistral.api.MistralAiApi.ChatCompletionChunk;
import org.springframework.ai.mistral.api.MistralAiApi.ChatCompletionMessage;
import org.springframework.http.ResponseEntity;
import static org.assertj.core.api.Assertions.assertThat;
/**
* @author Christian Tzolov
* @since 0.8.1
*/
@EnabledIfEnvironmentVariable(named = "MISTRAL_AI_API_KEY", matches = ".+")
public class MistralAiApiIT {
MistralAiApi mistralAiApi = new MistralAiApi(System.getenv("MISTRAL_AI_API_KEY"));
@Test
void chatCompletionEntity() {
ChatCompletionMessage chatCompletionMessage = new ChatCompletionMessage("Hello world", Role.USER);
ResponseEntity<ChatCompletion> response = mistralAiApi.chatCompletionEntity(new ChatCompletionRequest(
List.of(chatCompletionMessage), MistralAiApi.ChatModel.TINY.getValue(), 0.8f, false));
assertThat(response).isNotNull();
assertThat(response.getBody()).isNotNull();
}
@Test
void chatCompletionEntityWithSystemMessage() {
ChatCompletionMessage userMessage = new ChatCompletionMessage(
"Tell me about 3 famous pirates from the Golden Age of Piracy and why they did?", Role.USER);
ChatCompletionMessage systemMessage = new ChatCompletionMessage("""
You are an AI assistant that helps people find information.
Your name is Bob.
You should reply to the user's request with your name and also in the style of a pirate.
""", Role.SYSTEM);
ResponseEntity<ChatCompletion> response = mistralAiApi.chatCompletionEntity(new ChatCompletionRequest(
List.of(systemMessage, userMessage), MistralAiApi.ChatModel.TINY.getValue(), 0.8f, false));
assertThat(response).isNotNull();
assertThat(response.getBody()).isNotNull();
}
@Test
void chatCompletionStream() {
ChatCompletionMessage chatCompletionMessage = new ChatCompletionMessage("Hello world", Role.USER);
Flux<ChatCompletionChunk> response = mistralAiApi.chatCompletionStream(new ChatCompletionRequest(
List.of(chatCompletionMessage), MistralAiApi.ChatModel.TINY.getValue(), 0.8f, true));
assertThat(response).isNotNull();
assertThat(response.collectList().block()).isNotNull();
}
@Test
void embeddings() {
ResponseEntity<EmbeddingList<Embedding>> response = mistralAiApi
.embeddings(new MistralAiApi.EmbeddingRequest<String>("Hello world"));
assertThat(response).isNotNull();
assertThat(response.getBody().data()).hasSize(1);
assertThat(response.getBody().data().get(0).embedding()).hasSize(1024);
}
}

View File

@@ -0,0 +1,66 @@
/*
* Copyright 2024-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.mistral.embedding;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
import org.springframework.ai.embedding.EmbeddingRequest;
import org.springframework.ai.mistral.MistralAiEmbeddingClient;
import org.springframework.ai.mistral.MistralAiEmbeddingOptions;
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;
@SpringBootTest
@EnabledIfEnvironmentVariable(named = "MISTRAL_AI_API_KEY", matches = ".+")
class MistralEmbeddingIT {
@Autowired
private MistralAiEmbeddingClient mistralAiEmbeddingClient;
@Test
void defaultEmbedding() {
assertThat(mistralAiEmbeddingClient).isNotNull();
var embeddingResponse = mistralAiEmbeddingClient.embedForResponse(List.of("Hello World"));
assertThat(embeddingResponse.getResults()).hasSize(1);
assertThat(embeddingResponse.getResults().get(0)).isNotNull();
assertThat(embeddingResponse.getResults().get(0).getOutput()).hasSize(1024);
assertThat(embeddingResponse.getMetadata()).containsEntry("model", "mistral-embed");
assertThat(embeddingResponse.getMetadata()).containsEntry("total-tokens", 4);
assertThat(embeddingResponse.getMetadata()).containsEntry("prompt-tokens", 4);
assertThat(mistralAiEmbeddingClient.dimensions()).isEqualTo(1024);
}
@Test
void embeddingTest() {
assertThat(mistralAiEmbeddingClient).isNotNull();
var embeddingResponse = mistralAiEmbeddingClient.call(new EmbeddingRequest(
List.of("Hello World", "World is big"),
MistralAiEmbeddingOptions.builder().withModel("mistral-embed").withEncodingFormat("float").build()));
assertThat(embeddingResponse.getResults()).hasSize(2);
assertThat(embeddingResponse.getResults().get(0)).isNotNull();
assertThat(embeddingResponse.getResults().get(0).getOutput()).hasSize(1024);
assertThat(embeddingResponse.getMetadata()).containsEntry("model", "mistral-embed");
assertThat(embeddingResponse.getMetadata()).containsEntry("total-tokens", 9);
assertThat(embeddingResponse.getMetadata()).containsEntry("prompt-tokens", 9);
assertThat(mistralAiEmbeddingClient.dimensions()).isEqualTo(1024);
}
}

View File

@@ -0,0 +1,7 @@
You're assisting with questions about products in a bicycle catalog.
Use the information from the DOCUMENTS section to provide accurate answers.
The the answer involves referring to the price or the dimension of the bicycle, include the bicycle name in the response.
If unsure, simply state that you don't know.
DOCUMENTS:
{documents}

View File

@@ -0,0 +1,3 @@
You are an AI assistant who helps users to evaluate if the answers to questions are accurate.
You will be provided with a QUESTION and an ANSWER.
Your goal is to evaluate the QUESTION and ANSWER and reply with a YES or NO answer.

View File

@@ -0,0 +1,7 @@
You are an AI evaluator. Your task is to verify if the provided ANSWER is a direct and accurate response to the given QUESTION. If the ANSWER is correct and directly answers the QUESTION, reply with "YES". If the ANSWER is not a direct response or is inaccurate, reply with "NO".
For example:
If the QUESTION is "What is the capital of France?" and the ANSWER is "Paris.", you should respond with "YES".
If the QUESTION is "What is the capital of France?" and the ANSWER is "France is in Europe.", respond with "NO".
Now, evaluate the following:

View File

@@ -0,0 +1,4 @@
You are an AI assistant who helps users to evaluate if the answers to questions are accurate.
You will be provided with a QUESTION and an ANSWER.
A previous evaluation has determined that QUESTION and ANSWER are not related.
Give an explanation as to why they are not related.

View File

@@ -0,0 +1,6 @@
The question and answer to evaluate are:
QUESTION: ```{question}```
ANSWER: ```{answer}```

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

@@ -24,6 +24,7 @@
<module>models/spring-ai-openai</module>
<module>models/spring-ai-vertex-ai</module>
<module>models/spring-ai-stabilityai</module>
<module>models/spring-ai-mistral-ai</module>
<module>spring-ai-test</module>
<module>spring-ai-spring-boot-autoconfigure</module>
<module>spring-ai-spring-boot-starters/spring-ai-starter-openai</module>
@@ -53,6 +54,7 @@
<module>vector-stores/spring-ai-redis</module>
<module>spring-ai-spring-boot-starters/spring-ai-starter-vertex-ai</module>
<module>spring-ai-spring-boot-starters/spring-ai-starter-bedrock-ai</module>
<module>spring-ai-spring-boot-starters/spring-ai-starter-mistral-ai</module>
</modules>
<organization>

View File

@@ -101,6 +101,12 @@
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-mistral-ai</artifactId>
<version>${project.version}</version>
</dependency>
<!-- Vector Databses -->
<dependency>
<groupId>org.springframework.ai</groupId>

View File

@@ -179,6 +179,14 @@
<optional>true</optional>
</dependency>
<!-- Mistral AI LLM -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-mistral-ai</artifactId>
<version>${project.parent.version}</version>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>

View File

@@ -0,0 +1,84 @@
/*
* Copyright 2024-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.mistralai;
import org.springframework.ai.embedding.EmbeddingClient;
import org.springframework.ai.mistral.MistralAiChatClient;
import org.springframework.ai.mistral.MistralAiEmbeddingClient;
import org.springframework.ai.mistral.api.MistralAiApi;
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.annotation.Bean;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
import org.springframework.web.client.RestClient;
/**
* @author Ricken Bazolo
* @author Christian Tzolov
* @since 0.8.1
*/
@AutoConfiguration(after = { RestClientAutoConfiguration.class })
@EnableConfigurationProperties({ MistralAiEmbeddingProperties.class, MistralAiCommonProperties.class,
MistralAiChatProperties.class })
@ConditionalOnClass(MistralAiApi.class)
public class MistralAiAutoConfiguration {
@Bean
@ConditionalOnMissingBean
@ConditionalOnProperty(prefix = MistralAiEmbeddingProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true",
matchIfMissing = true)
public EmbeddingClient mistralAiEmbeddingClient(MistralAiCommonProperties commonProperties,
MistralAiEmbeddingProperties embeddingProperties, RestClient.Builder restClientBuilder) {
var mistralAiApi = mistralAiApi(embeddingProperties.getApiKey(), commonProperties.getApiKey(),
embeddingProperties.getBaseUrl(), commonProperties.getBaseUrl(), restClientBuilder);
return new MistralAiEmbeddingClient(mistralAiApi, embeddingProperties.getMetadataMode(),
embeddingProperties.getOptions());
}
@Bean
@ConditionalOnMissingBean
@ConditionalOnProperty(prefix = MistralAiChatProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true",
matchIfMissing = true)
public MistralAiChatClient mistralAiChatClient(MistralAiCommonProperties commonProperties,
MistralAiChatProperties chatProperties, RestClient.Builder restClientBuilder) {
var mistralAiApi = mistralAiApi(chatProperties.getApiKey(), commonProperties.getApiKey(),
chatProperties.getBaseUrl(), commonProperties.getBaseUrl(), restClientBuilder);
return new MistralAiChatClient(mistralAiApi, chatProperties.getOptions());
}
private MistralAiApi mistralAiApi(String apiKey, String commonApiKey, String baseUrl, String commonBaseUrl,
RestClient.Builder restClientBuilder) {
var resolvedApiKey = StringUtils.hasText(apiKey) ? apiKey : commonApiKey;
var resoledBaseUrl = StringUtils.hasText(baseUrl) ? baseUrl : commonBaseUrl;
Assert.hasText(resolvedApiKey, "Mistral API key must be set");
Assert.hasText(resoledBaseUrl, "Mistral base URL must be set");
return new MistralAiApi(resoledBaseUrl, resolvedApiKey, restClientBuilder);
}
}

View File

@@ -0,0 +1,74 @@
/*
* Copyright 2024-204 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.mistralai;
import org.springframework.ai.mistral.MistralAiChatOptions;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.NestedConfigurationProperty;
/**
* @author Ricken Bazolo
* @author Christian Tzolov
* @since 0.8.1
*/
@ConfigurationProperties(MistralAiChatProperties.CONFIG_PREFIX)
public class MistralAiChatProperties extends MistralAiParentProperties {
public static final String CONFIG_PREFIX = "spring.ai.mistral.chat";
public static final String DEFAULT_CHAT_MODEL = "mistral-tiny";
private static final Double DEFAULT_TEMPERATURE = 0.7;
private static final Float DEFAULT_TOP_P = 1.0f;
private static final Boolean IS_ENABLED = false;
public MistralAiChatProperties() {
super.setBaseUrl(MistralAiCommonProperties.DEFAULT_BASE_URL);
}
/**
* Enable OpenAI chat client.
*/
private boolean enabled = true;
@NestedConfigurationProperty
private MistralAiChatOptions options = MistralAiChatOptions.builder()
.withModel(DEFAULT_CHAT_MODEL)
.withTemperature(DEFAULT_TEMPERATURE.floatValue())
.withSafePrompt(!IS_ENABLED)
.withTopP(DEFAULT_TOP_P)
.build();
public MistralAiChatOptions getOptions() {
return this.options;
}
public void setOptions(MistralAiChatOptions options) {
this.options = options;
}
public boolean isEnabled() {
return this.enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
}

View File

@@ -0,0 +1,37 @@
/*
* Copyright 2024-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.mistralai;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* @author Ricken Bazolo
* @author Christian Tzolov
* @since 0.8.1
*/
@ConfigurationProperties(MistralAiCommonProperties.CONFIG_PREFIX)
public class MistralAiCommonProperties extends MistralAiParentProperties {
public static final String CONFIG_PREFIX = "spring.ai.mistralai";
public static final String DEFAULT_BASE_URL = "https://api.mistral.ai";
public MistralAiCommonProperties() {
super.setBaseUrl(DEFAULT_BASE_URL);
}
}

View File

@@ -0,0 +1,78 @@
/*
* Copyright 2024-204 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.mistralai;
import org.springframework.ai.document.MetadataMode;
import org.springframework.ai.mistral.MistralAiEmbeddingOptions;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.NestedConfigurationProperty;
/**
* @author Ricken Bazolo
* @since 0.8.1
*/
@ConfigurationProperties(MistralAiEmbeddingProperties.CONFIG_PREFIX)
public class MistralAiEmbeddingProperties extends MistralAiParentProperties {
public static final String CONFIG_PREFIX = "spring.ai.mistralai.embedding";
public static final String DEFAULT_EMBEDDING_MODEL = "mistral-embed";
public static final String DEFAULT_ENCODING_FORMAT = "float";
/**
* Enable MistralAI embedding client.
*/
private boolean enabled = true;
public MetadataMode metadataMode = MetadataMode.EMBED;
@NestedConfigurationProperty
private MistralAiEmbeddingOptions options = MistralAiEmbeddingOptions.builder()
.withModel(DEFAULT_EMBEDDING_MODEL)
.withEncodingFormat(DEFAULT_ENCODING_FORMAT)
.build();
public MistralAiEmbeddingProperties() {
super.setBaseUrl(MistralAiCommonProperties.DEFAULT_BASE_URL);
}
public MistralAiEmbeddingOptions getOptions() {
return this.options;
}
public void setOptions(MistralAiEmbeddingOptions 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,45 @@
/*
* Copyright 2024-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.mistralai;
/**
* @author Ricken Bazolo
* @since 0.8.1
*/
public class MistralAiParentProperties {
private String apiKey;
private String baseUrl;
public String getApiKey() {
return this.apiKey;
}
public void setApiKey(String apiKey) {
this.apiKey = apiKey;
}
public String getBaseUrl() {
return this.baseUrl;
}
public void setBaseUrl(String baseUrl) {
this.baseUrl = baseUrl;
}
}

View File

@@ -11,6 +11,7 @@ org.springframework.ai.autoconfigure.bedrock.anthropic.BedrockAnthropicChatAutoC
org.springframework.ai.autoconfigure.bedrock.titan.BedrockTitanChatAutoConfiguration
org.springframework.ai.autoconfigure.bedrock.titan.BedrockTitanEmbeddingAutoConfiguration
org.springframework.ai.autoconfigure.ollama.OllamaAutoConfiguration
org.springframework.ai.autoconfigure.mistralai.MistralAiAutoConfiguration
org.springframework.ai.autoconfigure.vectorstore.pgvector.PgVectorStoreAutoConfiguration
org.springframework.ai.autoconfigure.vectorstore.pinecone.PineconeVectorStoreAutoConfiguration
org.springframework.ai.autoconfigure.vectorstore.milvus.MilvusVectorStoreAutoConfiguration

View File

@@ -0,0 +1,94 @@
/*
* 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.mistralai;
import java.util.List;
import java.util.stream.Collectors;
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 reactor.core.publisher.Flux;
import org.springframework.ai.chat.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.mistral.MistralAiChatClient;
import org.springframework.ai.mistral.MistralAiEmbeddingClient;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import static org.assertj.core.api.Assertions.assertThat;
/**
* @author Christian Tzolov
* @since 0.8.1
*/
@EnabledIfEnvironmentVariable(named = "MISTRAL_AI_API_KEY", matches = ".*")
public class MistralAiAutoConfigurationIT {
private static final Log logger = LogFactory.getLog(MistralAiAutoConfigurationIT.class);
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
.withPropertyValues("spring.ai.mistralai.apiKey=" + System.getenv("MISTRAL_AI_API_KEY"))
.withConfiguration(AutoConfigurations.of(RestClientAutoConfiguration.class, MistralAiAutoConfiguration.class));
@Test
void generate() {
contextRunner.run(context -> {
MistralAiChatClient client = context.getBean(MistralAiChatClient.class);
String response = client.call("Hello");
assertThat(response).isNotEmpty();
logger.info("Response: " + response);
});
}
@Test
void generateStreaming() {
contextRunner.run(context -> {
MistralAiChatClient client = context.getBean(MistralAiChatClient.class);
Flux<ChatResponse> responseFlux = client.stream(new Prompt(new UserMessage("Hello")));
String response = responseFlux.collectList().block().stream().map(chatResponse -> {
return chatResponse.getResults().get(0).getOutput().getContent();
}).collect(Collectors.joining());
assertThat(response).isNotEmpty();
logger.info("Response: " + response);
});
}
@Test
void embedding() {
contextRunner.run(context -> {
MistralAiEmbeddingClient embeddingClient = context.getBean(MistralAiEmbeddingClient.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);
});
}
}

View File

@@ -0,0 +1,97 @@
/*
* Copyright 2024-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.mistralai;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Unit Tests for {@link MistralAiCommonProperties}, {@link MistralAiEmbeddingProperties}.
*/
public class MistralAiPropertiesTests {
@Test
public void embeddingProperties() {
new ApplicationContextRunner()
.withPropertyValues("spring.ai.mistralai.base-url=TEST_BASE_URL", "spring.ai.mistralai.api-key=abc123",
"spring.ai.mistralai.embedding.options.model=MODEL_XYZ")
.withConfiguration(
AutoConfigurations.of(RestClientAutoConfiguration.class, MistralAiAutoConfiguration.class))
.run(context -> {
var embeddingProperties = context.getBean(MistralAiEmbeddingProperties.class);
var connectionProperties = context.getBean(MistralAiCommonProperties.class);
assertThat(connectionProperties.getApiKey()).isEqualTo("abc123");
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("spring.ai.mistralai.base-url=TEST_BASE_URL",
"spring.ai.mistralai.api-key=abc123", "spring.ai.mistralai.embedding.base-url=TEST_BASE_URL2",
"spring.ai.mistralai.embedding.api-key=456", "spring.ai.mistralai.embedding.options.model=MODEL_XYZ")
.withConfiguration(
AutoConfigurations.of(RestClientAutoConfiguration.class, MistralAiAutoConfiguration.class))
.run(context -> {
var embeddingProperties = context.getBean(MistralAiEmbeddingProperties.class);
var connectionProperties = context.getBean(MistralAiCommonProperties.class);
assertThat(connectionProperties.getApiKey()).isEqualTo("abc123");
assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL");
assertThat(embeddingProperties.getApiKey()).isEqualTo("456");
assertThat(embeddingProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL2");
assertThat(embeddingProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ");
});
}
@Test
public void embeddingOptionsTest() {
new ApplicationContextRunner()
.withPropertyValues("spring.ai.mistralai.api-key=API_KEY", "spring.ai.mistralai.base-url=TEST_BASE_URL",
"spring.ai.mistralai.embedding.options.model=MODEL_XYZ",
"spring.ai.mistralai.embedding.options.encodingFormat=MyEncodingFormat")
.withConfiguration(
AutoConfigurations.of(RestClientAutoConfiguration.class, MistralAiAutoConfiguration.class))
.run(context -> {
var connectionProperties = context.getBean(MistralAiCommonProperties.class);
var embeddingProperties = context.getBean(MistralAiEmbeddingProperties.class);
assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL");
assertThat(connectionProperties.getApiKey()).isEqualTo("API_KEY");
assertThat(embeddingProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ");
assertThat(embeddingProperties.getOptions().getEncodingFormat()).isEqualTo("MyEncodingFormat");
});
}
}

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>0.8.1-SNAPSHOT</version>
<relativePath>../../pom.xml</relativePath>
</parent>
<artifactId>spring-ai-mistral-ai-spring-boot-starter</artifactId>
<packaging>jar</packaging>
<name>Spring AI Starter - MistralAI</name>
<description>Spring AI MistralAI 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-mistral-ai</artifactId>
<version>${project.parent.version}</version>
</dependency>
</dependencies>
</project>