diff --git a/models/spring-ai-mistral-ai/pom.xml b/models/spring-ai-mistral-ai/pom.xml new file mode 100644 index 000000000..a613e4f6a --- /dev/null +++ b/models/spring-ai-mistral-ai/pom.xml @@ -0,0 +1,70 @@ + + + 4.0.0 + + org.springframework.ai + spring-ai + 0.8.1-SNAPSHOT + ../../pom.xml + + spring-ai-mistral-ai + jar + Spring AI Mistral AI + Mistral AI support + https://github.com/spring-projects/spring-ai + + + https://github.com/spring-projects/spring-ai + git://github.com/spring-projects/spring-ai.git + git@github.com:spring-projects/spring-ai.git + + + + + + + org.springframework.ai + spring-ai-core + ${project.parent.version} + + + + org.springframework + spring-web + ${spring-framework.version} + + + + org.springframework.retry + spring-retry + 2.0.4 + + + + + + org.springframework + spring-webflux + + + org.springframework + spring-context-support + + + + org.springframework.boot + spring-boot-starter-logging + + + + + org.springframework.ai + spring-ai-test + ${project.version} + test + + + + + diff --git a/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistral/MistralAiChatClient.java b/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistral/MistralAiChatClient.java new file mode 100644 index 000000000..1e78161f2 --- /dev/null +++ b/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistral/MistralAiChatClient.java @@ -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 void onError(RetryContext context, + RetryCallback 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 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 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 roleMap = new ConcurrentHashMap<>(); + + return completionChunks.map(chunk -> { + String chunkId = chunk.id(); + List 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); + }); + }); + } + +} diff --git a/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistral/MistralAiChatOptions.java b/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistral/MistralAiChatOptions.java new file mode 100644 index 000000000..9cdc96f9b --- /dev/null +++ b/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistral/MistralAiChatOptions.java @@ -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'"); + } + +} diff --git a/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistral/MistralAiEmbeddingClient.java b/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistral/MistralAiEmbeddingClient.java new file mode 100644 index 000000000..2e9b6c60e --- /dev/null +++ b/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistral/MistralAiEmbeddingClient.java @@ -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 void onError(RetryContext context, + RetryCallback 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 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; + } + +} diff --git a/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistral/MistralAiEmbeddingOptions.java b/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistral/MistralAiEmbeddingOptions.java new file mode 100644 index 000000000..3fd2cce76 --- /dev/null +++ b/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistral/MistralAiEmbeddingOptions.java @@ -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; + } + + } + +} diff --git a/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistral/api/MistralAiApi.java b/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistral/api/MistralAiApi.java new file mode 100644 index 000000000..f36d996f2 --- /dev/null +++ b/models/spring-ai-mistral-ai/src/main/java/org/springframework/ai/mistral/api/MistralAiApi.java @@ -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: + * ... and Chat + * Completion API: + * ... + * + * @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 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 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 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 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( + // @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 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( + // @formatter:off + @JsonProperty("object") String object, + @JsonProperty("data") List 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 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: + * + *
{@code List.of("text1", "text2", "text3") or List.of(List.of(1, 2, 3), List.of(3, 4, 5))} 
+ */ + public ResponseEntity> 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.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 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 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 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 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 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 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 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 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 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 parseJson(String jsonSchema) { + try { + return new ObjectMapper().readValue(jsonSchema, new TypeReference>() { + }); + } + catch (Exception e) { + throw new MistralAiApiException("Failed to parse schema: " + jsonSchema, e); + } + } + + private T parseJson(String json, Class type) { + try { + return this.objectMapper.readValue(json, type); + } + catch (Exception e) { + throw new MistralAiApiException("Failed to parse schema: " + json, e); + } + } + +} diff --git a/models/spring-ai-mistral-ai/src/test/java/org/springframework/ai/mistral/MistralAiTestConfiguration.java b/models/spring-ai-mistral-ai/src/test/java/org/springframework/ai/mistral/MistralAiTestConfiguration.java new file mode 100644 index 000000000..270c303e7 --- /dev/null +++ b/models/spring-ai-mistral-ai/src/test/java/org/springframework/ai/mistral/MistralAiTestConfiguration.java @@ -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()); + } + +} diff --git a/models/spring-ai-mistral-ai/src/test/java/org/springframework/ai/mistral/chat/MistralAiChatClientIT.java b/models/spring-ai-mistral-ai/src/test/java/org/springframework/ai/mistral/chat/MistralAiChatClientIT.java new file mode 100644 index 000000000..a74d9a061 --- /dev/null +++ b/models/spring-ai-mistral-ai/src/test/java/org/springframework/ai/mistral/chat/MistralAiChatClientIT.java @@ -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 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 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 movies) { + } + + @Test + void beanOutputParserRecords() { + + BeanOutputParser 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 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); + } + +} diff --git a/models/spring-ai-mistral-ai/src/test/java/org/springframework/ai/mistral/chat/MistralChatCompletionRequestTest.java b/models/spring-ai-mistral-ai/src/test/java/org/springframework/ai/mistral/chat/MistralChatCompletionRequestTest.java new file mode 100644 index 000000000..43969f600 --- /dev/null +++ b/models/spring-ai-mistral-ai/src/test/java/org/springframework/ai/mistral/chat/MistralChatCompletionRequestTest.java @@ -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(); + } + +} diff --git a/models/spring-ai-mistral-ai/src/test/java/org/springframework/ai/mistral/chat/api/MistralAiApiIT.java b/models/spring-ai-mistral-ai/src/test/java/org/springframework/ai/mistral/chat/api/MistralAiApiIT.java new file mode 100644 index 000000000..698c0ad10 --- /dev/null +++ b/models/spring-ai-mistral-ai/src/test/java/org/springframework/ai/mistral/chat/api/MistralAiApiIT.java @@ -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 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 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 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> response = mistralAiApi + .embeddings(new MistralAiApi.EmbeddingRequest("Hello world")); + + assertThat(response).isNotNull(); + assertThat(response.getBody().data()).hasSize(1); + assertThat(response.getBody().data().get(0).embedding()).hasSize(1024); + } + +} diff --git a/models/spring-ai-mistral-ai/src/test/java/org/springframework/ai/mistral/embedding/MistralEmbeddingIT.java b/models/spring-ai-mistral-ai/src/test/java/org/springframework/ai/mistral/embedding/MistralEmbeddingIT.java new file mode 100644 index 000000000..845306742 --- /dev/null +++ b/models/spring-ai-mistral-ai/src/test/java/org/springframework/ai/mistral/embedding/MistralEmbeddingIT.java @@ -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); + } + +} diff --git a/models/spring-ai-mistral-ai/src/test/resources/prompts/acme/system-qa.st b/models/spring-ai-mistral-ai/src/test/resources/prompts/acme/system-qa.st new file mode 100644 index 000000000..44db6f210 --- /dev/null +++ b/models/spring-ai-mistral-ai/src/test/resources/prompts/acme/system-qa.st @@ -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} \ No newline at end of file diff --git a/models/spring-ai-mistral-ai/src/test/resources/prompts/eval/qa-evaluator-accurate-answer.st b/models/spring-ai-mistral-ai/src/test/resources/prompts/eval/qa-evaluator-accurate-answer.st new file mode 100644 index 000000000..562703595 --- /dev/null +++ b/models/spring-ai-mistral-ai/src/test/resources/prompts/eval/qa-evaluator-accurate-answer.st @@ -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. \ No newline at end of file diff --git a/models/spring-ai-mistral-ai/src/test/resources/prompts/eval/qa-evaluator-fact-based-answer.st b/models/spring-ai-mistral-ai/src/test/resources/prompts/eval/qa-evaluator-fact-based-answer.st new file mode 100644 index 000000000..22fc3e88d --- /dev/null +++ b/models/spring-ai-mistral-ai/src/test/resources/prompts/eval/qa-evaluator-fact-based-answer.st @@ -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: diff --git a/models/spring-ai-mistral-ai/src/test/resources/prompts/eval/qa-evaluator-not-related-message.st b/models/spring-ai-mistral-ai/src/test/resources/prompts/eval/qa-evaluator-not-related-message.st new file mode 100644 index 000000000..7c33e675e --- /dev/null +++ b/models/spring-ai-mistral-ai/src/test/resources/prompts/eval/qa-evaluator-not-related-message.st @@ -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. \ No newline at end of file diff --git a/models/spring-ai-mistral-ai/src/test/resources/prompts/eval/user-evaluator-message.st b/models/spring-ai-mistral-ai/src/test/resources/prompts/eval/user-evaluator-message.st new file mode 100644 index 000000000..b3fa3e902 --- /dev/null +++ b/models/spring-ai-mistral-ai/src/test/resources/prompts/eval/user-evaluator-message.st @@ -0,0 +1,6 @@ +The question and answer to evaluate are: + +QUESTION: ```{question}``` + +ANSWER: ```{answer}``` + diff --git a/models/spring-ai-mistral-ai/src/test/resources/prompts/system-message.st b/models/spring-ai-mistral-ai/src/test/resources/prompts/system-message.st new file mode 100644 index 000000000..579febd8d --- /dev/null +++ b/models/spring-ai-mistral-ai/src/test/resources/prompts/system-message.st @@ -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}. \ No newline at end of file diff --git a/pom.xml b/pom.xml index 6714d1d9b..4b8e5448b 100644 --- a/pom.xml +++ b/pom.xml @@ -24,6 +24,7 @@ models/spring-ai-openai models/spring-ai-vertex-ai models/spring-ai-stabilityai + models/spring-ai-mistral-ai spring-ai-test spring-ai-spring-boot-autoconfigure spring-ai-spring-boot-starters/spring-ai-starter-openai @@ -53,6 +54,7 @@ vector-stores/spring-ai-redis spring-ai-spring-boot-starters/spring-ai-starter-vertex-ai spring-ai-spring-boot-starters/spring-ai-starter-bedrock-ai + spring-ai-spring-boot-starters/spring-ai-starter-mistral-ai diff --git a/spring-ai-bom/pom.xml b/spring-ai-bom/pom.xml index 8ceb795fc..fca06adf4 100644 --- a/spring-ai-bom/pom.xml +++ b/spring-ai-bom/pom.xml @@ -101,6 +101,12 @@ ${project.version} + + org.springframework.ai + spring-ai-mistral-ai + ${project.version} + + org.springframework.ai diff --git a/spring-ai-spring-boot-autoconfigure/pom.xml b/spring-ai-spring-boot-autoconfigure/pom.xml index 1412f135e..663f111ca 100644 --- a/spring-ai-spring-boot-autoconfigure/pom.xml +++ b/spring-ai-spring-boot-autoconfigure/pom.xml @@ -179,6 +179,14 @@ true + + + org.springframework.ai + spring-ai-mistral-ai + ${project.parent.version} + true + + org.springframework.boot spring-boot-configuration-processor diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mistralai/MistralAiAutoConfiguration.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mistralai/MistralAiAutoConfiguration.java new file mode 100644 index 000000000..78dd43d3f --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mistralai/MistralAiAutoConfiguration.java @@ -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); + } + +} diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mistralai/MistralAiChatProperties.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mistralai/MistralAiChatProperties.java new file mode 100644 index 000000000..3410cd287 --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mistralai/MistralAiChatProperties.java @@ -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; + } + +} diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mistralai/MistralAiCommonProperties.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mistralai/MistralAiCommonProperties.java new file mode 100644 index 000000000..142d8d5cc --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mistralai/MistralAiCommonProperties.java @@ -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); + } + +} diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mistralai/MistralAiEmbeddingProperties.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mistralai/MistralAiEmbeddingProperties.java new file mode 100644 index 000000000..f745fe9e9 --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mistralai/MistralAiEmbeddingProperties.java @@ -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; + } + +} diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mistralai/MistralAiParentProperties.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mistralai/MistralAiParentProperties.java new file mode 100644 index 000000000..f3844ad6d --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mistralai/MistralAiParentProperties.java @@ -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; + } + +} diff --git a/spring-ai-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/spring-ai-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index 240fdc4db..0118aa9c0 100644 --- a/spring-ai-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/spring-ai-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -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 diff --git a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/mistralai/MistralAiAutoConfigurationIT.java b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/mistralai/MistralAiAutoConfigurationIT.java new file mode 100644 index 000000000..6fa58170c --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/mistralai/MistralAiAutoConfigurationIT.java @@ -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 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); + }); + } + +} diff --git a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/mistralai/MistralAiPropertiesTests.java b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/mistralai/MistralAiPropertiesTests.java new file mode 100644 index 000000000..d4f007ae8 --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/mistralai/MistralAiPropertiesTests.java @@ -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"); + }); + } + +} diff --git a/spring-ai-spring-boot-starters/spring-ai-starter-mistral-ai/pom.xml b/spring-ai-spring-boot-starters/spring-ai-starter-mistral-ai/pom.xml new file mode 100644 index 000000000..632126b34 --- /dev/null +++ b/spring-ai-spring-boot-starters/spring-ai-starter-mistral-ai/pom.xml @@ -0,0 +1,42 @@ + + + 4.0.0 + + org.springframework.ai + spring-ai + 0.8.1-SNAPSHOT + ../../pom.xml + + spring-ai-mistral-ai-spring-boot-starter + jar + Spring AI Starter - MistralAI + Spring AI MistralAI Auto Configuration + https://github.com/spring-projects/spring-ai + + + https://github.com/spring-projects/spring-ai + git://github.com/spring-projects/spring-ai.git + git@github.com:spring-projects/spring-ai.git + + + + + + org.springframework.boot + spring-boot-starter + + + + org.springframework.ai + spring-ai-spring-boot-autoconfigure + ${project.parent.version} + + + + org.springframework.ai + spring-ai-mistral-ai + ${project.parent.version} + + + +