From bc81de19e42ac4b0221cfce4909d9f7901708e99 Mon Sep 17 00:00:00 2001 From: Ricken Bazolo Date: Tue, 19 Aug 2025 13:35:38 +0200 Subject: [PATCH 01/18] added cohere support part 1 - chat model Signed-off-by: ricken07 --- .../pom.xml | 96 +++ .../CohereChatAutoConfiguration.java | 88 ++ .../autoconfigure/CohereChatProperties.java | 46 + .../autoconfigure/CohereCommonProperties.java | 21 + .../autoconfigure/CohereParentProperties.java | 30 + ...itional-spring-configuration-metadata.json | 11 + ...ot.autoconfigure.AutoConfiguration.imports | 16 + .../CohereAutoConfigurationIT.java | 35 + .../CohereModelConfigurationTests.java | 35 + .../autoconfigure/CoherePropertiesTests.java | 63 ++ models/spring-ai-cohere/README.md | 0 models/spring-ai-cohere/pom.xml | 74 ++ .../ai/cohere/aot/CohereRuntimeHints.java | 28 + .../ai/cohere/api/CohereApi.java | 795 ++++++++++++++++++ .../ai/cohere/chat/CohereChatModel.java | 446 ++++++++++ .../ai/cohere/chat/CohereChatOptions.java | 516 ++++++++++++ .../resources/META-INF/spring/aot.factories | 2 + pom.xml | 4 + spring-ai-bom/pom.xml | 18 + .../ai/model/SpringAIModels.java | 2 + .../spring-ai-starter-model-cohere/pom.xml | 54 ++ 21 files changed, 2380 insertions(+) create mode 100644 auto-configurations/models/spring-ai-autoconfigure-model-cohere/pom.xml create mode 100644 auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/java/org/springframework/ai/cohere/autoconfigure/CohereChatAutoConfiguration.java create mode 100644 auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/java/org/springframework/ai/cohere/autoconfigure/CohereChatProperties.java create mode 100644 auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/java/org/springframework/ai/cohere/autoconfigure/CohereCommonProperties.java create mode 100644 auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/java/org/springframework/ai/cohere/autoconfigure/CohereParentProperties.java create mode 100644 auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/resources/META-INF/additional-spring-configuration-metadata.json create mode 100644 auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/test/java/org/springframework/ai/cohere/autoconfigure/CohereAutoConfigurationIT.java create mode 100644 auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/test/java/org/springframework/ai/cohere/autoconfigure/CohereModelConfigurationTests.java create mode 100644 auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/test/java/org/springframework/ai/cohere/autoconfigure/CoherePropertiesTests.java create mode 100644 models/spring-ai-cohere/README.md create mode 100644 models/spring-ai-cohere/pom.xml create mode 100644 models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/aot/CohereRuntimeHints.java create mode 100644 models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/api/CohereApi.java create mode 100644 models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/chat/CohereChatModel.java create mode 100644 models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/chat/CohereChatOptions.java create mode 100644 models/spring-ai-cohere/src/main/resources/META-INF/spring/aot.factories create mode 100644 spring-ai-spring-boot-starters/spring-ai-starter-model-cohere/pom.xml diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-cohere/pom.xml b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/pom.xml new file mode 100644 index 00000000000..8424c3b124b --- /dev/null +++ b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/pom.xml @@ -0,0 +1,96 @@ + + + 4.0.0 + + org.springframework.ai + spring-ai-parent + 1.1.0-SNAPSHOT + ../../../pom.xml + + spring-ai-autoconfigure-model-cohere + jar + Spring AI Cohere Auto Configuration + Spring AI Cohere 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.ai + spring-ai-cohere + ${project.parent.version} + true + + + + + + org.springframework.ai + spring-ai-autoconfigure-model-tool + ${project.parent.version} + + + + org.springframework.ai + spring-ai-autoconfigure-retry + ${project.parent.version} + + + + org.springframework.ai + spring-ai-autoconfigure-model-chat-observation + ${project.parent.version} + + + + + org.springframework.boot + spring-boot-starter + true + + + + org.springframework.boot + spring-boot-configuration-processor + true + + + + org.springframework.boot + spring-boot-autoconfigure-processor + true + + + + + org.springframework.ai + spring-ai-test + ${project.parent.version} + test + + + + org.springframework.boot + spring-boot-starter-test + test + + + + org.mockito + mockito-core + test + + + + diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/java/org/springframework/ai/cohere/autoconfigure/CohereChatAutoConfiguration.java b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/java/org/springframework/ai/cohere/autoconfigure/CohereChatAutoConfiguration.java new file mode 100644 index 00000000000..b703d63dbe3 --- /dev/null +++ b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/java/org/springframework/ai/cohere/autoconfigure/CohereChatAutoConfiguration.java @@ -0,0 +1,88 @@ +package org.springframework.ai.cohere.autoconfigure; + +import io.micrometer.observation.ObservationRegistry; +import org.springframework.ai.chat.observation.ChatModelObservationConvention; +import org.springframework.ai.cohere.api.CohereApi; +import org.springframework.ai.cohere.chat.CohereChatModel; +import org.springframework.ai.model.SpringAIModelProperties; +import org.springframework.ai.model.SpringAIModels; +import org.springframework.ai.model.tool.DefaultToolExecutionEligibilityPredicate; +import org.springframework.ai.model.tool.ToolCallingManager; +import org.springframework.ai.model.tool.ToolExecutionEligibilityPredicate; +import org.springframework.ai.model.tool.autoconfigure.ToolCallingAutoConfiguration; +import org.springframework.ai.retry.autoconfigure.SpringAiRetryAutoConfiguration; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.ImportAutoConfiguration; +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.retry.support.RetryTemplate; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; +import org.springframework.web.client.ResponseErrorHandler; +import org.springframework.web.client.RestClient; +import org.springframework.web.reactive.function.client.WebClient; + +/** + * Chat {@link AutoConfiguration Auto-configuration} for Cohere. + * + * @author Ricken Bazolo + */ +@AutoConfiguration(after = { RestClientAutoConfiguration.class, SpringAiRetryAutoConfiguration.class, + ToolCallingAutoConfiguration.class }) +@EnableConfigurationProperties({ CohereCommonProperties.class, CohereChatProperties.class }) +@ConditionalOnProperty(name = SpringAIModelProperties.CHAT_MODEL, havingValue = SpringAIModels.COHERE, + matchIfMissing = true) +@ConditionalOnClass(CohereApi.class) +@ImportAutoConfiguration(classes = { SpringAiRetryAutoConfiguration.class, RestClientAutoConfiguration.class, + ToolCallingAutoConfiguration.class }) +public class CohereChatAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + public CohereChatModel chereChatModel(CohereCommonProperties commonProperties, + CohereChatProperties chatProperties, ObjectProvider restClientBuilderProvider, + ObjectProvider webClientBuilderProvider, ToolCallingManager toolCallingManager, + RetryTemplate retryTemplate, ResponseErrorHandler responseErrorHandler, + ObjectProvider observationRegistry, + ObjectProvider observationConvention, + ObjectProvider cohereToolExecutionEligibilityPredicate) { + var cohereApi = cohereApi(chatProperties.getApiKey(), commonProperties.getApiKey(), + chatProperties.getBaseUrl(), commonProperties.getBaseUrl(), + restClientBuilderProvider.getIfAvailable(RestClient::builder), + webClientBuilderProvider.getIfAvailable(WebClient::builder), responseErrorHandler); + + var chatModel = CohereChatModel.builder() + .cohereApi(cohereApi) + .defaultOptions(chatProperties.getOptions()) + .toolCallingManager(toolCallingManager) + .toolExecutionEligibilityPredicate(cohereToolExecutionEligibilityPredicate + .getIfUnique(DefaultToolExecutionEligibilityPredicate::new)) + .retryTemplate(retryTemplate) + .observationRegistry(observationRegistry.getIfUnique(() -> ObservationRegistry.NOOP)) + .build(); + + observationConvention.ifAvailable(chatModel::setObservationConvention); + + return chatModel; + } + + private CohereApi cohereApi(String apiKey, String commonApiKey, String baseUrl, String commonBaseUrl, + RestClient.Builder restClientBuilder, WebClient.Builder webClientBuilder, + ResponseErrorHandler responseErrorHandler) { + + var resolvedApiKey = StringUtils.hasText(apiKey) ? apiKey : commonApiKey; + var resoledBaseUrl = StringUtils.hasText(baseUrl) ? baseUrl : commonBaseUrl; + + Assert.hasText(resolvedApiKey, "Cohere API key must be set"); + Assert.hasText(resoledBaseUrl, "Cohere base URL must be set"); + + return new CohereApi(resoledBaseUrl, resolvedApiKey, restClientBuilder, webClientBuilder, + responseErrorHandler); + } + +} diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/java/org/springframework/ai/cohere/autoconfigure/CohereChatProperties.java b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/java/org/springframework/ai/cohere/autoconfigure/CohereChatProperties.java new file mode 100644 index 00000000000..14c1b9c55d8 --- /dev/null +++ b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/java/org/springframework/ai/cohere/autoconfigure/CohereChatProperties.java @@ -0,0 +1,46 @@ +package org.springframework.ai.cohere.autoconfigure; + +import org.springframework.ai.cohere.api.CohereApi; +import org.springframework.ai.cohere.chat.CohereChatOptions; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.NestedConfigurationProperty; + +/** + * Configuration properties for Cohere chat. + * + * @author Ricken Bazolo + */ +@ConfigurationProperties(CohereChatProperties.CONFIG_PREFIX) +public class CohereChatProperties extends CohereParentProperties { + + public static final String CONFIG_PREFIX = "spring.ai.cohere.chat"; + + public static final String DEFAULT_CHAT_MODEL = CohereApi.ChatModel.COMMAND_R7B.getValue(); + + private static final Double DEFAULT_TEMPERATURE = 0.3; + + private static final Double DEFAULT_TOP_P = 1.0; + + @NestedConfigurationProperty + private CohereChatOptions options = CohereChatOptions.builder() + .model(DEFAULT_CHAT_MODEL) + .temperature(DEFAULT_TEMPERATURE) + .topP(DEFAULT_TOP_P) + .presencePenalty(0.0) + .frequencyPenalty(0.0) + .logprobs(false) + .build(); + + public CohereChatProperties() { + super.setBaseUrl(CohereCommonProperties.DEFAULT_BASE_URL); + } + + public CohereChatOptions getOptions() { + return this.options; + } + + public void setOptions(CohereChatOptions options) { + this.options = options; + } + +} diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/java/org/springframework/ai/cohere/autoconfigure/CohereCommonProperties.java b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/java/org/springframework/ai/cohere/autoconfigure/CohereCommonProperties.java new file mode 100644 index 00000000000..b4596bbc0da --- /dev/null +++ b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/java/org/springframework/ai/cohere/autoconfigure/CohereCommonProperties.java @@ -0,0 +1,21 @@ +package org.springframework.ai.cohere.autoconfigure; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Common properties for Cohere. + * + * @author Ricken Bazolo + */ +@ConfigurationProperties(CohereCommonProperties.CONFIG_PREFIX) +public class CohereCommonProperties extends CohereParentProperties { + + public static final String CONFIG_PREFIX = "spring.ai.cohere"; + + public static final String DEFAULT_BASE_URL = "https://api.cohere.com"; + + public CohereCommonProperties() { + super.setBaseUrl(DEFAULT_BASE_URL); + } + +} diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/java/org/springframework/ai/cohere/autoconfigure/CohereParentProperties.java b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/java/org/springframework/ai/cohere/autoconfigure/CohereParentProperties.java new file mode 100644 index 00000000000..8b1c75d1d38 --- /dev/null +++ b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/java/org/springframework/ai/cohere/autoconfigure/CohereParentProperties.java @@ -0,0 +1,30 @@ +package org.springframework.ai.cohere.autoconfigure; + +/** + * Parent properties for Cohere. + * + * @author Ricken Bazolo + */ +public class CohereParentProperties { + + 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/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/resources/META-INF/additional-spring-configuration-metadata.json new file mode 100644 index 00000000000..5952657975c --- /dev/null +++ b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -0,0 +1,11 @@ +{ + "groups": [ + { + "name": "spring.ai.cohere.chat.options.tool-choice", + "type": "org.springframework.ai.cohere.api.CohereApi$ChatCompletionRequest$ToolChoice", + "sourceType": "org.springframework.ai.cohere.chat.CohereChatOptions" + } + ], + "properties": [], + "hints": [] +} diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000000..c59639d63f7 --- /dev/null +++ b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,16 @@ +# +# Copyright 2025-2025 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. +# +org.springframework.ai.cohere.autoconfigure.CohereChatAutoConfiguration diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/test/java/org/springframework/ai/cohere/autoconfigure/CohereAutoConfigurationIT.java b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/test/java/org/springframework/ai/cohere/autoconfigure/CohereAutoConfigurationIT.java new file mode 100644 index 00000000000..1d506793612 --- /dev/null +++ b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/test/java/org/springframework/ai/cohere/autoconfigure/CohereAutoConfigurationIT.java @@ -0,0 +1,35 @@ +package org.springframework.ai.cohere.autoconfigure; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.ai.cohere.chat.CohereChatModel; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Ricken Bazolo + */ +@EnabledIfEnvironmentVariable(named = "COHERE_API_KEY", matches = ".*") +public class CohereAutoConfigurationIT { + + private static final Log logger = LogFactory.getLog(CohereAutoConfigurationIT.class); + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withPropertyValues("spring.ai.cohere.apiKey=" + System.getenv("COHERE_API_KEY")); + + @Test + void generate() { + this.contextRunner.withConfiguration(AutoConfigurations.of(CohereChatAutoConfiguration.class)) + .run(context -> { + CohereChatModel chatModel = context.getBean(CohereChatModel.class); + String response = chatModel.call("Hello"); + assertThat(response).isNotEmpty(); + logger.info("Response: " + response); + }); + } + +} diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/test/java/org/springframework/ai/cohere/autoconfigure/CohereModelConfigurationTests.java b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/test/java/org/springframework/ai/cohere/autoconfigure/CohereModelConfigurationTests.java new file mode 100644 index 00000000000..7ba664a3075 --- /dev/null +++ b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/test/java/org/springframework/ai/cohere/autoconfigure/CohereModelConfigurationTests.java @@ -0,0 +1,35 @@ +package org.springframework.ai.cohere.autoconfigure; + +import org.junit.jupiter.api.Test; +import org.springframework.ai.cohere.chat.CohereChatModel; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit Tests for Cohere auto-configurations conditional enabling of models. + * + * @author Ricken Bazolo + */ +public class CohereModelConfigurationTests { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withPropertyValues("spring.ai.cohere.apiKey=" + System.getenv("COHERE_API_KEY")); + + @Test + void chatModelActivation() { + this.contextRunner.withConfiguration(AutoConfigurations.of(CohereChatAutoConfiguration.class)) + .run(context -> { + assertThat(context.getBeansOfType(CohereChatProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(CohereChatModel.class)).isNotEmpty(); + }); + + this.contextRunner.withConfiguration(AutoConfigurations.of(CohereChatAutoConfiguration.class)) + .withPropertyValues("spring.ai.model.chat=none") + .run(context -> { + assertThat(context.getBeansOfType(CohereChatProperties.class)).isEmpty(); + assertThat(context.getBeansOfType(CohereChatModel.class)).isEmpty(); + }); + } +} diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/test/java/org/springframework/ai/cohere/autoconfigure/CoherePropertiesTests.java b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/test/java/org/springframework/ai/cohere/autoconfigure/CoherePropertiesTests.java new file mode 100644 index 00000000000..7068ab404b1 --- /dev/null +++ b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/test/java/org/springframework/ai/cohere/autoconfigure/CoherePropertiesTests.java @@ -0,0 +1,63 @@ +package org.springframework.ai.cohere.autoconfigure; + +import org.junit.jupiter.api.Test; +import org.springframework.ai.cohere.api.CohereApi; +import org.springframework.ai.retry.autoconfigure.SpringAiRetryAutoConfiguration; +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 CohereCommonProperties}. + */ +public class CoherePropertiesTests { + + @Test + public void chatOptionsTest() { + + new ApplicationContextRunner().withPropertyValues( + "spring.ai.cohere.base-url=TEST_BASE_URL", + "spring.ai.cohere.api-key=abc123", + "spring.ai.cohere.chat.options.tools[0].function.name=myFunction1", + "spring.ai.cohere.chat.options.tools[0].function.description=function description", + "spring.ai.cohere.chat.options.tools[0].function.jsonSchema=" + """ + { + "type": "object", + "properties": { + "location": { + "type": "string", + "description": "The city and state e.g. San Francisco, CA" + }, + "lat": { + "type": "number", + "description": "The city latitude" + }, + "lon": { + "type": "number", + "description": "The city longitude" + }, + "unit": { + "type": "string", + "enum": ["c", "f"] + } + }, + "required": ["location", "lat", "lon", "unit"] + } + """) + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, CohereChatAutoConfiguration.class)) + .run(context -> { + + var chatProperties = context.getBean(CohereChatProperties.class); + + var tool = chatProperties.getOptions().getTools().get(0); + assertThat(tool.getType()).isEqualTo(CohereApi.FunctionTool.Type.FUNCTION); + var function = tool.getFunction(); + assertThat(function.getName()).isEqualTo("myFunction1"); + assertThat(function.getDescription()).isEqualTo("function description"); + assertThat(function.getParameters()).isNotEmpty(); + }); + } +} diff --git a/models/spring-ai-cohere/README.md b/models/spring-ai-cohere/README.md new file mode 100644 index 00000000000..e69de29bb2d diff --git a/models/spring-ai-cohere/pom.xml b/models/spring-ai-cohere/pom.xml new file mode 100644 index 00000000000..1e549082161 --- /dev/null +++ b/models/spring-ai-cohere/pom.xml @@ -0,0 +1,74 @@ + + + 4.0.0 + + org.springframework.ai + spring-ai-parent + 1.1.0-SNAPSHOT + ../../pom.xml + + spring-ai-cohere + jar + Spring AI Model - Cohere + Cohere models support + https://github.com/spring-projects/spring-ai + + + https://github.com/spring-projects/spring-ai + git://github.com/spring-projects/spring-ai.git + git@github.com:spring-projects/spring-ai.git + + + + + + + + + + org.springframework.ai + spring-ai-model + ${project.parent.version} + + + + org.springframework.ai + spring-ai-retry + ${project.parent.version} + + + + + org.springframework + spring-context-support + + + + org.springframework + spring-webflux + + + + org.slf4j + slf4j-api + + + + + org.springframework.ai + spring-ai-test + ${project.version} + test + + + + io.micrometer + micrometer-observation-test + test + + + + + diff --git a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/aot/CohereRuntimeHints.java b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/aot/CohereRuntimeHints.java new file mode 100644 index 00000000000..591e0096c59 --- /dev/null +++ b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/aot/CohereRuntimeHints.java @@ -0,0 +1,28 @@ +package org.springframework.ai.cohere.aot; + +import org.springframework.aot.hint.MemberCategory; +import org.springframework.aot.hint.RuntimeHints; +import org.springframework.aot.hint.RuntimeHintsRegistrar; +import org.springframework.lang.NonNull; +import org.springframework.lang.Nullable; + +import static org.springframework.ai.aot.AiRuntimeHints.findJsonAnnotatedClassesInPackage; + +/** + * The CohereRuntimeHints class is responsible for registering runtime hints for Cohere AI + * API classes. + * + * @author Ricken Bazolo + */ +public class CohereRuntimeHints implements RuntimeHintsRegistrar { + + @Override + public void registerHints(@NonNull RuntimeHints hints, @Nullable ClassLoader classLoader) { + var mcs = MemberCategory.values(); + + for (var tr : findJsonAnnotatedClassesInPackage("org.springframework.ai.cohere")) { + hints.reflection().registerType(tr, mcs); + } + } + +} diff --git a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/api/CohereApi.java b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/api/CohereApi.java new file mode 100644 index 00000000000..2ed6a5920ea --- /dev/null +++ b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/api/CohereApi.java @@ -0,0 +1,795 @@ +package org.springframework.ai.cohere.api; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import org.springframework.ai.model.ChatModelDescription; +import org.springframework.ai.model.ModelOptionsUtils; +import org.springframework.ai.observation.conventions.AiProvider; +import org.springframework.ai.retry.RetryUtils; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.util.Assert; +import org.springframework.web.client.ResponseErrorHandler; +import org.springframework.web.client.RestClient; +import org.springframework.web.reactive.function.client.WebClient; + +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; +import java.util.function.Predicate; + +/** + * Java Client library for Cohere Platform. Provides implementation for the + * Chat + * and Chat Stream + * Embedding API. + *

+ * Implements Synchronous and Streaming chat completion and supports latest + * Function Calling features. + *

+ * + * @author Ricken Bazolo + */ +public class CohereApi { + + public static final String PROVIDER_NAME = AiProvider.MISTRAL_AI.value(); + + private static final String DEFAULT_BASE_URL = "https://api.cohere.com"; + + private static final Predicate SSE_DONE_PREDICATE = "[DONE]"::equals; + + private final RestClient restClient; + + private final WebClient webClient; + + // TODO ADD Stream helper + + /** + * Create a new client api with DEFAULT_BASE_URL + * @param cohereApiKey Cohere api Key. + */ + public CohereApi(String cohereApiKey) { + this(DEFAULT_BASE_URL, cohereApiKey); + } + + /** + * Create a new client api. + * @param baseUrl api base URL. + * @param cohereApiKey Cohere api Key. + */ + public CohereApi(String baseUrl, String cohereApiKey) { + this(baseUrl, cohereApiKey, RestClient.builder(), WebClient.builder(), RetryUtils.DEFAULT_RESPONSE_ERROR_HANDLER); + } + + /** + * Create a new client api. + * @param baseUrl api base URL. + * @param cohereApiKey Cohere api Key. + * @param restClientBuilder RestClient builder. + * @param responseErrorHandler Response error handler. + */ + public CohereApi(String baseUrl, String cohereApiKey, RestClient.Builder restClientBuilder, WebClient.Builder webClientBuilder, + ResponseErrorHandler responseErrorHandler) { + + Consumer jsonContentHeaders = headers -> { + headers.setBearerAuth(cohereApiKey); + headers.setContentType(MediaType.APPLICATION_JSON); + }; + + this.restClient = restClientBuilder.baseUrl(baseUrl) + .defaultHeaders(jsonContentHeaders) + .defaultStatusHandler(responseErrorHandler) + .build(); + + this.webClient = webClientBuilder.clone().baseUrl(baseUrl).defaultHeaders(jsonContentHeaders).build(); + } + + /** + * List of well-known Cohere chat + * models. + * + *

+ * Cohere provides Command family of models includes: Command A, Command R7B, Command + * R+, Command R, and Command. + */ + public enum ChatModel implements ChatModelDescription { + + COMMAND_A("command-a-03-2025"), COMMAND_R7B("command-r7b-12-2024"), + COMMAND_R_PLUS_08_2024("command-r-plus-08-2024"), COMMAND_R_PLUS("command-r-plus"), COMMAND_R("command-r"), + COMMAND_03_2024("command-r-03-2024"); + + private final String value; + + ChatModel(String value) { + this.value = value; + } + + public String getValue() { + return this.value; + } + + @Override + public String getName() { + return this.value; + } + + } + + /** + * Usage statistics. + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public record Usage(@JsonProperty("billedUnits") BilledUnits billedUnits, @JsonProperty("tokens") Tokens tokens) { + /** + * Bille units + * + * @param inputTokens The number of billed input tokens. + * @param outputTokens The number of billed output tokens. + * @param searchUnits The number of billed search units. + * @param classifications The number of billed classifications units. + */ + public record BilledUnits(@JsonProperty("input_tokens") Integer inputTokens, + @JsonProperty("output_tokens") Integer outputTokens, @JsonProperty("search_units") Double searchUnits, + @JsonProperty("classifications") Double classifications) { + } + + /** + * The Tokens + * + * @param inputTokens The number of tokens used as input to the model. + * @param outputTokens The number of tokens produced by the model. + */ + public record Tokens(@JsonProperty("input_tokens") Integer inputTokens, + @JsonProperty("output_tokens") Integer outputTokens) { + } + } + + /** + * Creates a model request for chat conversation. + * + * @param model The name of a compatible Cohere model or the ID of a fine-tuned model. + * @param messages The prompt(s) to generate completions for, encoded as a list of + * dict with role and rawContent. The first prompt role should be user or system. + * @param tools A list of tools the model may call. Currently, only functions are + * supported as a tool. Use this to provide a list of functions the model may generate + * JSON inputs for. + * @param documents A list of relevant documents that the model can cite to generate a + * more accurate reply. Each document is either a string or document object with + * rawContent and metadata. + * @param citationOptions Options for controlling citation generation. + * @param responseFormat An object specifying the format or schema that the model must + * output. Setting to { "type": "json_object" } enables JSON mode, which guarantees + * the message the model generates is valid JSON. Setting to { "type": "json_object" , + * "json_schema": schema} allows you to ensure the model provides an answer in a very + * specific JSON format by supplying a clear JSON schema. + * @param safetyMode Safety modes are not yet configurable in combination with tools, + * tool_results and documents parameters. + * @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 stopSequences A list of tokens that the model should stop generating after. + * If set, + * @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 p + * but not both. + * @param seed If specified, the backend will make a best effort to sample tokens + * deterministically, such that repeated requests with the same seed and parameters + * should return the same result. However, determinism cannot be totally guaranteed. + * @param frequencyPenalty Number between 0.0 and 1.0. Used to reduce repetitiveness + * of generated tokens. The higher the value, the stronger a penalty is applied to + * previously present tokens, proportional to how many times they have already + * appeared in the prompt or prior generation. + * @param presencePenalty min value of 0.0, max value of 1.0. Used to reduce + * repetitiveness of generated tokens. Similar to frequency_penalty, except that this + * penalty is applied equally to all tokens that have already appeared, regardless of + * their exact frequencies. + * @param stream When true, the response will be a SSE stream of events. The final + * event will contain the complete response, and will have an event_type of + * "stream-end". + * @param k Ensures that only the top k most likely tokens are considered for + * generation at each step. When k is set to 0, k-sampling is disabled. Defaults to 0, + * min value of 0, max value of 500. + * @param p Ensures that only the most likely tokens, with total probability mass of + * p, are considered for generation at each step. If both k and p are enabled, p acts + * after k. Defaults to 0.75. min value of 0.01, max value of 0.99. + * @param logprobs Defaults to false. When set to true, the log probabilities of the + * generated tokens will be included in the response. + * @param toolChoice Used to control whether or not the model will be forced to use a + * tool when answering. When REQUIRED is specified, the model will be forced to use at + * least one of the user-defined tools, and the tools parameter must be passed in the + * request. When NONE is specified, the model will be forced not to use one of the + * specified tools, and give a direct response. If tool_choice isn’t specified, then + * the model is free to choose whether to use the specified tools or not. + * @param strictTools When set to true, tool calls in the Assistant message will be + * forced to follow the tool definition strictly. + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public record ChatCompletionRequest(@JsonProperty("model") String model, + @JsonProperty("messages") List messages, + @JsonProperty("tools") List tools, @JsonProperty("documents") List documents, + @JsonProperty("citation_options") CitationOptions citationOptions, + @JsonProperty("response_format") ResponseFormat responseFormat, + @JsonProperty("safety_mode") SafetyMode safetyMode, @JsonProperty("max_tokens") Integer maxTokens, + @JsonProperty("stop_sequences") List stopSequences, @JsonProperty("temperature") Double temperature, + @JsonProperty("seed") Integer seed, @JsonProperty("frequency_penalty") Double frequencyPenalty, + @JsonProperty("stream") Boolean stream, @JsonProperty("k") Integer k, @JsonProperty("p") Double p, + @JsonProperty("logprobs") Boolean logprobs, @JsonProperty("tool_choice") ToolChoice toolChoice, + @JsonProperty("strict_tools") Boolean strictTools, + @JsonProperty("presence_penalty") Double presencePenalty) { + + /** + * 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 rawContent. The first prompt role should be user or system. + * @param model ID or name of the model to use. + */ + public ChatCompletionRequest(List messages, String model) { + this(model, messages, null, null, new CitationOptions(CitationMode.FAST), null, SafetyMode.CONTEXTUAL, null, null, 0.3, + null, null, false, 0, 0.75, false, null, 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 rawContent. The first prompt role should be user or system. + * @param model ID or model 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, Double temperature, + boolean stream) { + this(model, messages, null, null, new CitationOptions(CitationMode.FAST), null, SafetyMode.CONTEXTUAL, null, null, + temperature, null, null, stream, 0, 0.75, false, null, 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 rawContent. 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, Double temperature) { + this(model, messages, null, null, new CitationOptions(CitationMode.FAST), null, SafetyMode.CONTEXTUAL, null, null, + temperature, null, null, false, 0, 0.75, false, null, false, null); + } + + /** + * Shortcut constructor for a chat completion request with the given messages, + * model, tools and tool choice. Streaming is set to false, temperature to 0.8 and + * all other parameters are null. + * @param messages A list of messages comprising the conversation so far. + * @param model ID of the model to use. + * @param tools A list of tools the model may call. Currently, only functions are + * supported as a tool. + * @param toolChoice Controls which (if any) function is called by the model. + */ + public ChatCompletionRequest(List messages, String model, List tools, + ToolChoice toolChoice) { + this(model, messages, tools, null, new CitationOptions(CitationMode.FAST), null, SafetyMode.CONTEXTUAL, null, null, 0.75, + null, null, false, 0, 0.75, false, toolChoice, false, null); + } + + /** + * Shortcut constructor for a chat completion request with the given messages and + * stream. + */ + public ChatCompletionRequest(List messages, Boolean stream) { + this(null, messages, null, null, new CitationOptions(CitationMode.FAST), null, SafetyMode.CONTEXTUAL, null, null, 0.75, + null, null, stream, 0, 0.75, false, null, false, null); + } + + /** + * An object specifying the format that the model must output. + * + * @param type Must be one of 'text' or 'json_object'. + * @param jsonSchema A specific JSON schema to match, if 'type' is 'json_object'. + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public record ResponseFormat(@JsonProperty("type") String type, + @JsonProperty("json_schema") Map jsonSchema) { + } + + /** + * Specifies a tool the model should use + */ + public enum ToolChoice { + + REQUIRED, NONE + + } + + } + + /** + * Message comprising the conversation. A message from the assistant role can contain + * text and tool call information. + * + * @param role The role of the messages author. Could be one of the {@link Role} types + * "assistant". + * @param toolCalls The tool calls generated by the model, such as function calls. + * Applicable only for {@link Role#ASSISTANT} role and null otherwise. + * @param toolPlan A chain-of-thought style reflection and plan that the model + * generates when working with Tools. + * @param rawContent The contents of the message. Can be either a {@link MediaContent} or + * a {@link MessageContent}. + * @param citations Tool call that this message is responding to. Only applicable for + * the {@link ChatCompletionFinishReason#TOOL_CALL} role and null otherwise. + */ + public record ChatCompletionMessage( + @JsonProperty("content") Object rawContent, + @JsonProperty("role") Role role, + //@JsonProperty("name") String name, + @JsonProperty("tool_plan") String toolPlan, + @JsonProperty("tool_calls") List toolCalls, + @JsonProperty("citations") List citations) { + + public ChatCompletionMessage(Object content, Role role) { + this(content, role, null, null, null); + } + + public ChatCompletionMessage(Object content, Role role, List toolCalls) { + this(content, role, null, toolCalls, null); + } + + /** + * An array of rawContent parts with a defined type. Each MediaContent can be of + * either "text" or "image_url" type. Only one option allowed. + * + * @param type Content type, each can be of type text or image_url. + * @param text The text rawContent of the message. + * @param imageUrl The image rawContent of the message. + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public record MediaContent(@JsonProperty("type") String type, @JsonProperty("text") String text, + @JsonProperty("image_url") ImageUrl imageUrl) { + + /** + * Shortcut constructor for a text rawContent. + * @param text The text rawContent of the message. + */ + public MediaContent(String text) { + this("text", text, null); + } + + /** + * Shortcut constructor for an image rawContent. + * @param imageUrl The image rawContent of the message. + */ + public MediaContent(ImageUrl imageUrl) { + this("image_url", null, imageUrl); + } + + /** + * Shortcut constructor for an image rawContent. + * + * @param url Either a URL of the image or the base64 encoded image data. The + * base64 encoded image data must have a special prefix in the following + * format: "data:{mimetype};base64,{base64-encoded-image-data}". + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public record ImageUrl(@JsonProperty("url") String url) { + + } + } + + + /** + * Message rawContent that can be either a text or a value. + * + * @param type The type of the message rawContent, such as "text" or "thinking". + * @param text The text rawContent of the message. + * @param value The value of the thinking, which can be any object. + */ + public record MessageContent( + @JsonProperty("type") String type, + @JsonProperty("text") String text, + @JsonProperty("value") Object value) {} + + /** + * The role of the author of this message. + */ + public enum Role { + + /** + * User message. + */ + @JsonProperty("user") + USER, + /** + * Assistant message. + */ + @JsonProperty("assistant") + ASSISTANT, + /** + * System message. + */ + @JsonProperty("system") + SYSTEM, + /** + * Tool message. + */ + @JsonProperty("tool") + TOOL + + } + + /** + * The relevant tool call. + * + * @param id The ID of the tool call. This ID must be referenced when you submit + * the tool outputs in using the Submit tool outputs to run endpoint. + * @param type The type of tool call the output is required for. For now, this is + * always function. + * @param function The function definition. + * @param index The index of the tool call in the list of tool calls. + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public record ToolCall(@JsonProperty("id") String id, @JsonProperty("type") String type, + @JsonProperty("function") ChatCompletionFunction function, @JsonProperty("index") Integer index) { + } + + /** + * The function definition. + * + * @param name The name of the function. + * @param arguments The arguments that the model expects you to pass to the + * function. + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public record ChatCompletionFunction(@JsonProperty("name") String name, + @JsonProperty("arguments") String arguments) { + } + + public record ChatCompletionCitation( + /** + * Start index of the cited snippet in the original source text. + */ + @JsonProperty("start") Integer start, + /** + * End index of the cited snippet in the original source text. + */ + @JsonProperty("end") Integer end, + /** + * Text snippet that is being cited. + */ + @JsonProperty("text") String text, @JsonProperty("sources") List sources, + @JsonProperty("type") Type type) { + /** + * The type of citation which indicates what part of the response the citation + * is for. + */ + public enum Type { + + TEXT_CONTENT, PLAN + + } + + /** + * @param type Tool or A document source object containing the unique + * identifier of the document and the document itself. + * @param id The unique identifier of the document + * @param toolOutput map from strings to any Optional if type == tool + * @param document map from strings to any Optional if type == document + */ + public record Source(@JsonProperty("type") String type, @JsonProperty("id") String id, + @JsonProperty("tool_output") Map toolOutput, + @JsonProperty("document") Map document) { + } + } + + public record Provider( + @JsonProperty("content") List content, + @JsonProperty("role") Role role, + @JsonProperty("tool_plan") String toolPlan, + @JsonProperty("tool_calls") List toolCalls, + @JsonProperty("citations") List citations + ) {} + } + + /** + * Used to select the safety instruction inserted into the prompt. Defaults to + * CONTEXTUAL. When OFF is specified, the safety instruction will be omitted. Safety + * modes are not yet configurable in combination with tools, tool_results and + * documents parameters. Note: This parameter is only compatible newer Cohere models, + * starting with Command R 08-2024 and Command R+ 08-2024. Note: command-r7b-12-2024 + * and newer models only support "CONTEXTUAL" and "STRICT" modes. + */ + public enum SafetyMode { + + CONTEXTUAL, STRICT, OFF + + } + + /** + * Options for controlling citation generation. Defaults to "accurate". Dictates the + * approach taken to generating citations as part of the RAG flow by allowing the user + * to specify whether they want "accurate" results, "fast" results or no results. + * Note: command-r7b-12-2024 and command-a-03-2025 only support "fast" and "off" + * modes. The default is "fast". + */ + public record CitationOptions(@JsonProperty("mode") CitationMode mode) {} + + /** + * Options for controlling citation generation. Defaults to "accurate". Dictates the + * approach taken to generating citations as part of the RAG flow by allowing the user + * to specify whether they want "accurate" results, "fast" results or no results. + * Note: command-r7b-12-2024 and command-a-03-2025 only support "fast" and "off" + * modes. The default is "fast". + */ + public enum CitationMode { + + FAST, ACCURATE, OFF + + } + + /** + * relevant documents that the model can cite to generate a more accurate reply. Each + * document is either a string or document object with rawContent and metadata. + * + * @param id An optional Unique identifier for this document which will be referenced + * in citations. If not provided an ID will be automatically generated. + * @param data A relevant document that the model can cite to generate a more accurate + * reply. Each document is a string-any dictionary. + */ + public record Document(@JsonProperty("id") String id, @JsonProperty("data") String data) { + } + + /** + * Represents a tool the model may call. Currently, only functions are supported as a + * tool. + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class FunctionTool { + + // The type of the tool. Currently, only 'function' is supported. + @JsonProperty("type") + Type type = Type.FUNCTION; + + // The function definition. + @JsonProperty("function") + Function function; + + public FunctionTool() { + + } + + /** + * Create a tool of type 'function' and the given function definition. + * @param function function definition. + */ + public FunctionTool(Function function) { + this(Type.FUNCTION, function); + } + + public FunctionTool(Type type, Function function) { + this.type = type; + this.function = function; + } + + public Type getType() { + return this.type; + } + + public Function getFunction() { + return this.function; + } + + public void setType(Type type) { + this.type = type; + } + + public void setFunction(Function function) { + this.function = function; + } + + /** + * Create a tool of type 'function' and the given function definition. + */ + public enum Type { + + /** + * Function tool type. + */ + @JsonProperty("function") + FUNCTION + + } + + /** + * Function definition. + */ + public static class Function { + + @JsonProperty("description") + private String description; + + @JsonProperty("name") + private String name; + + @JsonProperty("parameters") + private Map parameters; + + @JsonIgnore + private String jsonSchema; + + private Function() { + + } + + /** + * Create tool function definition. + * @param description A description of what the function does, used by the + * model to choose when and how to call the function. + * @param name The name of the function to be called. Must be a-z, A-Z, 0-9, + * or contain underscores and dashes, with a maximum length of 64. + * @param parameters The parameters the functions accepts, described as a JSON + * Schema object. To describe a function that accepts no parameters, provide + * the value {"type": "object", "properties": {}}. + */ + public Function(String description, String name, Map parameters) { + this.description = description; + this.name = name; + this.parameters = parameters; + } + + /** + * Create tool function definition. + * @param description tool function description. + * @param name tool function name. + * @param jsonSchema tool function schema as json. + */ + public Function(String description, String name, String jsonSchema) { + this(description, name, ModelOptionsUtils.jsonToMap(jsonSchema)); + } + + public String getDescription() { + return this.description; + } + + public String getName() { + return this.name; + } + + public Map getParameters() { + return this.parameters; + } + + public void setDescription(String description) { + this.description = description; + } + + public void setName(String name) { + this.name = name; + } + + public void setParameters(Map parameters) { + this.parameters = parameters; + } + + public String getJsonSchema() { + return this.jsonSchema; + } + + public void setJsonSchema(String jsonSchema) { + this.jsonSchema = jsonSchema; + if (jsonSchema != null) { + this.parameters = ModelOptionsUtils.jsonToMap(jsonSchema); + } + } + + } + + } + + /** + * Represents a chat completion response returned by model, based on the provided + * input. + * + * @param id A unique identifier for the chat completion. + * @param finishReason The reason the model stopped generating tokens. + * @param message A chat completion message generated by streamed model responses. + * @param logprobs Log probability information for the choice. + * @param usage Usage statistics for the completion request. + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public record ChatCompletion(@JsonProperty("id") String id, + @JsonProperty("finish_reason") ChatCompletionFinishReason finishReason, + @JsonProperty("message") ChatCompletionMessage.Provider message, + @JsonProperty("logprobs") LogProbs logprobs, + @JsonProperty("usage") Usage usage) { } + + /** + * The reason the model stopped generating tokens. + */ + public enum ChatCompletionFinishReason { + + /** + * The model finished sending a complete message. + */ + COMPLETE, + + /** + * One of the provided stop_sequence entries was reached in the model’s + * generation. + */ + STOP_SEQUENCE, + + /** + * The number of generated tokens exceeded the model’s context length or the value + * specified via the max_tokens parameter. + */ + MAX_TOKENS, + + /** + * The model generated a Tool Call and is expecting a Tool Message in return + */ + TOOL_CALL, + + /** + * The generation failed due to an internal error + */ + ERROR + + } + + /** + * Log probability information + * + * @param tokenIds The token ids of each token used to construct the text chunk. + * @param text The text chunk for which the log probabilities was calculated. + * @param logprobs The log probability of each token used to construct the text chunk. + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public record LogProbs(@JsonProperty("token_ids") List tokenIds, @JsonProperty("text") String text, + @JsonProperty("logprobs") List logprobs) { + + } + + /** + * Helper factory that creates a tool_choice of type 'REQUIRED', 'NONE' or selected + * function by name. + */ + public static class ToolChoiceBuilder { + + public static final String NONE = "NONE"; + + public static final String REQUIRED = "REQUIRED"; + + /** + * Specifying a particular function forces the model to call that function. + */ + public static Object FUNCTION(String functionName) { + return Map.of("type", "function", "function", Map.of("name", functionName)); + } + + } + + /** + * 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 stream property to false."); + + return this.restClient.post() + .uri("/v2/chat/") + .body(chatRequest) + .retrieve() + .toEntity(ChatCompletion.class); + } + + +} diff --git a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/chat/CohereChatModel.java b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/chat/CohereChatModel.java new file mode 100644 index 00000000000..2216444c500 --- /dev/null +++ b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/chat/CohereChatModel.java @@ -0,0 +1,446 @@ +package org.springframework.ai.cohere.chat; + +import io.micrometer.observation.ObservationRegistry; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.ai.chat.metadata.ChatGenerationMetadata; +import org.springframework.ai.cohere.api.CohereApi; +import org.springframework.ai.cohere.api.CohereApi.FunctionTool; +import org.springframework.ai.cohere.api.CohereApi.ChatCompletion; +import org.springframework.ai.cohere.api.CohereApi.ChatCompletionMessage; +import org.springframework.ai.cohere.api.CohereApi.ChatCompletionMessage.ChatCompletionFunction; +import org.springframework.ai.cohere.api.CohereApi.ChatCompletionMessage.ToolCall; +import org.springframework.ai.cohere.api.CohereApi.ChatCompletionMessage.Role; +import org.springframework.ai.cohere.api.CohereApi.ChatCompletionRequest; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.SystemMessage; +import org.springframework.ai.chat.messages.ToolResponseMessage; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.metadata.ChatResponseMetadata; +import org.springframework.ai.chat.metadata.DefaultUsage; +import org.springframework.ai.chat.metadata.Usage; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.model.Generation; +import org.springframework.ai.chat.observation.ChatModelObservationContext; +import org.springframework.ai.chat.observation.ChatModelObservationConvention; +import org.springframework.ai.chat.observation.ChatModelObservationDocumentation; +import org.springframework.ai.chat.observation.DefaultChatModelObservationConvention; +import org.springframework.ai.chat.prompt.ChatOptions; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.content.Media; +import org.springframework.ai.model.ModelOptionsUtils; +import org.springframework.ai.model.tool.*; +import org.springframework.ai.retry.RetryUtils; +import org.springframework.ai.support.UsageCalculator; +import org.springframework.ai.tool.definition.ToolDefinition; +import org.springframework.http.ResponseEntity; +import org.springframework.retry.support.RetryTemplate; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; +import org.springframework.util.MimeType; +import reactor.core.publisher.Flux; + +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.Map; + +/** + * Represents a Cohere Chat Model. + * + * @author Ricken Bazolo + */ +public class CohereChatModel implements ChatModel { + + private static final ChatModelObservationConvention DEFAULT_OBSERVATION_CONVENTION = new DefaultChatModelObservationConvention(); + + private static final ToolCallingManager DEFAULT_TOOL_CALLING_MANAGER = ToolCallingManager.builder().build(); + + private final Logger logger = LoggerFactory.getLogger(getClass()); + + /** + * The default options used for the chat completion requests. + */ + private final CohereChatOptions defaultOptions; + + /** + * Low-level access to the Cohere API. + */ + private final CohereApi cohereApi; + + private final RetryTemplate retryTemplate; + + /** + * Observation registry used for instrumentation. + */ + private final ObservationRegistry observationRegistry; + + private final ToolCallingManager toolCallingManager; + + /** + * The tool execution eligibility predicate used to determine if a tool can be + * executed. + */ + private final ToolExecutionEligibilityPredicate toolExecutionEligibilityPredicate; + + /** + * Conventions to use for generating observations. + */ + private ChatModelObservationConvention observationConvention = DEFAULT_OBSERVATION_CONVENTION; + + public CohereChatModel(CohereApi cohereApi, CohereChatOptions defaultOptions, + ToolCallingManager toolCallingManager, RetryTemplate retryTemplate, + ObservationRegistry observationRegistry) { + this(cohereApi, defaultOptions, toolCallingManager, retryTemplate, observationRegistry, + new DefaultToolExecutionEligibilityPredicate()); + } + + public CohereChatModel(CohereApi cohereApi, CohereChatOptions defaultOptions, + ToolCallingManager toolCallingManager, RetryTemplate retryTemplate, ObservationRegistry observationRegistry, + ToolExecutionEligibilityPredicate toolExecutionEligibilityPredicate) { + Assert.notNull(cohereApi, "cohereApi cannot be null"); + Assert.notNull(defaultOptions, "defaultOptions cannot be null"); + Assert.notNull(toolCallingManager, "toolCallingManager cannot be null"); + Assert.notNull(retryTemplate, "retryTemplate cannot be null"); + Assert.notNull(observationRegistry, "observationRegistry cannot be null"); + Assert.notNull(toolExecutionEligibilityPredicate, "toolExecutionEligibilityPredicate cannot be null"); + this.cohereApi = cohereApi; + this.defaultOptions = defaultOptions; + this.toolCallingManager = toolCallingManager; + this.retryTemplate = retryTemplate; + this.observationRegistry = observationRegistry; + this.toolExecutionEligibilityPredicate = toolExecutionEligibilityPredicate; + } + + public static ChatResponseMetadata from(CohereApi.ChatCompletion result) { + Assert.notNull(result, "Cohere ChatCompletion must not be null"); + DefaultUsage usage = getDefaultUsage(result.usage()); + return ChatResponseMetadata.builder() + .id(result.id()) + .usage(usage) + .build(); + } + + public static ChatResponseMetadata from(CohereApi.ChatCompletion result, Usage usage) { + Assert.notNull(result, "Cohere ChatCompletion must not be null"); + return ChatResponseMetadata.builder() + .id(result.id()) + .usage(usage) + .build(); + } + + private static DefaultUsage getDefaultUsage(CohereApi.Usage usage) { + return new DefaultUsage(null, null, null, usage); + } + + @Override + public ChatResponse call(Prompt prompt) { + Prompt requestPrompt = buildRequestPrompt(prompt); + return this.internalCall(requestPrompt, null); + } + + @Override + public Flux stream(Prompt prompt) { + Prompt requestPrompt = buildRequestPrompt(prompt); + return Flux.error(new UnsupportedOperationException("Streaming is not supported yet")); + } + + Prompt buildRequestPrompt(Prompt prompt) { + // Process runtime options + CohereChatOptions runtimeOptions = null; + if (prompt.getOptions() != null) { + if (prompt.getOptions() instanceof ToolCallingChatOptions toolCallingChatOptions) { + runtimeOptions = ModelOptionsUtils.copyToTarget(toolCallingChatOptions, ToolCallingChatOptions.class, + CohereChatOptions.class); + } + else { + runtimeOptions = ModelOptionsUtils.copyToTarget(prompt.getOptions(), ChatOptions.class, + CohereChatOptions.class); + } + } + + // Define request options by merging runtime options and default options + CohereChatOptions requestOptions = ModelOptionsUtils.merge(runtimeOptions, this.defaultOptions, + CohereChatOptions.class); + + // Merge @JsonIgnore-annotated options explicitly since they are ignored by + // Jackson, used by ModelOptionsUtils. + if (runtimeOptions != null) { + requestOptions.setInternalToolExecutionEnabled( + ModelOptionsUtils.mergeOption(runtimeOptions.getInternalToolExecutionEnabled(), + this.defaultOptions.getInternalToolExecutionEnabled())); + requestOptions.setToolNames(ToolCallingChatOptions.mergeToolNames(runtimeOptions.getToolNames(), + this.defaultOptions.getToolNames())); + requestOptions.setToolCallbacks(ToolCallingChatOptions.mergeToolCallbacks(runtimeOptions.getToolCallbacks(), + this.defaultOptions.getToolCallbacks())); + requestOptions.setToolContext(ToolCallingChatOptions.mergeToolContext(runtimeOptions.getToolContext(), + this.defaultOptions.getToolContext())); + } + else { + requestOptions.setInternalToolExecutionEnabled(this.defaultOptions.getInternalToolExecutionEnabled()); + requestOptions.setToolNames(this.defaultOptions.getToolNames()); + requestOptions.setToolCallbacks(this.defaultOptions.getToolCallbacks()); + requestOptions.setToolContext(this.defaultOptions.getToolContext()); + } + + ToolCallingChatOptions.validateToolCallbacks(requestOptions.getToolCallbacks()); + + return new Prompt(prompt.getInstructions(), requestOptions); + } + + public ChatResponse internalCall(Prompt prompt, ChatResponse previousChatResponse) { + + ChatCompletionRequest request = createRequest(prompt, false); + + ChatModelObservationContext observationContext = ChatModelObservationContext.builder() + .prompt(prompt) + .provider(CohereApi.PROVIDER_NAME) + .build(); + + ChatResponse response = ChatModelObservationDocumentation.CHAT_MODEL_OPERATION + .observation(this.observationConvention, DEFAULT_OBSERVATION_CONVENTION, () -> observationContext, + this.observationRegistry) + .observe(() -> { + + ResponseEntity completionEntity = this.retryTemplate + .execute(ctx -> this.cohereApi.chatCompletionEntity(request)); + + ChatCompletion chatCompletion = completionEntity.getBody(); + + if (chatCompletion == null) { + logger.warn("No chat completion returned for prompt: {}", prompt); + return new ChatResponse(List.of()); + } + + List generations = chatCompletion.message().content().stream().map(content -> { + Map metadata = Map.of( + "id", chatCompletion.id() != null ? chatCompletion.id() : "", + "role", chatCompletion.message().role() != null ? chatCompletion.message().role().name() : "", + "finishReason", chatCompletion.finishReason() != null ? chatCompletion.finishReason().name() : ""); + return buildGeneration(content, chatCompletion, metadata); + }).toList(); + + DefaultUsage usage = getDefaultUsage(completionEntity.getBody().usage()); + Usage cumulativeUsage = UsageCalculator.getCumulativeUsage(usage, previousChatResponse); + ChatResponse chatResponse = new ChatResponse(generations, + from(completionEntity.getBody(), cumulativeUsage)); + + observationContext.setResponse(chatResponse); + + return chatResponse; + }); + + if (this.toolExecutionEligibilityPredicate.isToolExecutionRequired(prompt.getOptions(), response)) { + var toolExecutionResult = this.toolCallingManager.executeToolCalls(prompt, response); + if (toolExecutionResult.returnDirect()) { + // Return tool execution result directly to the client. + return ChatResponse.builder() + .from(response) + .generations(ToolExecutionResult.buildGenerations(toolExecutionResult)) + .build(); + } + else { + // Send the tool execution result back to the model. + return this.internalCall(new Prompt(toolExecutionResult.conversationHistory(), prompt.getOptions()), + response); + } + } + + return response; + } + + private Generation buildGeneration(ChatCompletionMessage.MessageContent content, ChatCompletion completion, Map metadata) { + List toolCalls = completion.message().toolCalls() == null ? List.of() + : completion.message() + .toolCalls() + .stream() + .map(toolCall -> new AssistantMessage.ToolCall(toolCall.id(), "function", + toolCall.function().name(), toolCall.function().arguments())) + .toList(); + + var assistantMessage = new AssistantMessage(content.text(), metadata, toolCalls); + String finishReason = (completion.finishReason() != null ? completion.finishReason().name() : ""); + var generationMetadata = ChatGenerationMetadata.builder().finishReason(finishReason).build(); + return new Generation(assistantMessage, generationMetadata); + } + + /** + * Accessible for testing. + */ + CohereApi.ChatCompletionRequest createRequest(Prompt prompt, boolean stream) { + List chatCompletionMessages = prompt.getInstructions().stream().map(message -> { + if (message instanceof UserMessage userMessage) { + Object content = message.getText(); + + if (!CollectionUtils.isEmpty(userMessage.getMedia())) { + List contentList = new ArrayList<>( + List.of(new ChatCompletionMessage.MediaContent(message.getText()))); + + contentList.addAll(userMessage.getMedia().stream().map(this::mapToMediaContent).toList()); + + content = contentList; + } + + return List + .of(new ChatCompletionMessage(content, Role.USER)); + } + else if (message instanceof SystemMessage systemMessage) { + return List.of(new ChatCompletionMessage(systemMessage.getText(), + Role.SYSTEM)); + } + else if (message instanceof AssistantMessage assistantMessage) { + List toolCalls = null; + if (!CollectionUtils.isEmpty(assistantMessage.getToolCalls())) { + toolCalls = assistantMessage.getToolCalls().stream().map(toolCall -> { + var function = new ChatCompletionFunction(toolCall.name(), toolCall.arguments()); + return new ToolCall(toolCall.id(), toolCall.type(), function, null); + }).toList(); + } + + return List.of(new ChatCompletionMessage(assistantMessage.getText(), + Role.ASSISTANT, toolCalls)); + } + else if (message instanceof ToolResponseMessage toolResponseMessage) { + toolResponseMessage.getResponses() + .forEach(response -> Assert.isTrue(response.id() != null, "ToolResponseMessage must have an id")); + + return toolResponseMessage.getResponses() + .stream() + .map(toolResponse -> new ChatCompletionMessage(toolResponse.responseData(), + Role.TOOL)) + .toList(); + } + else { + throw new IllegalStateException("Unexpected message type: " + message); + } + }).flatMap(List::stream).toList(); + + var request = new ChatCompletionRequest(chatCompletionMessages, stream); + + CohereChatOptions requestOptions = (CohereChatOptions) prompt.getOptions(); + request = ModelOptionsUtils.merge(requestOptions, request, ChatCompletionRequest.class); + + // Add the tool definitions to the request's tools parameter. + List toolDefinitions = this.toolCallingManager.resolveToolDefinitions(requestOptions); + if (!CollectionUtils.isEmpty(toolDefinitions)) { + request = ModelOptionsUtils.merge( + CohereChatOptions.builder().tools(this.getFunctionTools(toolDefinitions)).build(), request, + ChatCompletionRequest.class); + } + + return request; + } + + private ChatCompletionMessage.MediaContent mapToMediaContent(Media media) { + return new ChatCompletionMessage.MediaContent(new ChatCompletionMessage.MediaContent.ImageUrl( + this.fromMediaData(media.getMimeType(), media.getData()))); + } + + private String fromMediaData(MimeType mimeType, Object mediaContentData) { + if (mediaContentData instanceof byte[] bytes) { + // Assume the bytes are an image. So, convert the bytes to a base64 encoded + // following the prefix pattern. + return String.format("data:%s;base64,%s", mimeType.toString(), Base64.getEncoder().encodeToString(bytes)); + } + else if (mediaContentData instanceof String text) { + // Assume the text is a URLs or a base64 encoded image prefixed by the user. + return text; + } + else { + throw new IllegalArgumentException( + "Unsupported media data type: " + mediaContentData.getClass().getSimpleName()); + } + } + + private List getFunctionTools(List toolDefinitions) { + return toolDefinitions.stream().map(toolDefinition -> { + var function = new FunctionTool.Function(toolDefinition.description(), toolDefinition.name(), + toolDefinition.inputSchema()); + return new FunctionTool(function); + }).toList(); + } + + @Override + public ChatOptions getDefaultOptions() { + return CohereChatOptions.fromOptions(this.defaultOptions); + } + + /** + * Use the provided convention for reporting observation data + * @param observationConvention The provided convention + */ + public void setObservationConvention(ChatModelObservationConvention observationConvention) { + Assert.notNull(observationConvention, "observationConvention cannot be null"); + this.observationConvention = observationConvention; + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + + private CohereApi cohereApi; + + private CohereChatOptions defaultOptions = CohereChatOptions.builder() + .temperature(0.3) + .topP(1.0) + .model(CohereApi.ChatModel.COMMAND_R7B.getValue()) + .build(); + + private ToolCallingManager toolCallingManager; + + private ToolExecutionEligibilityPredicate toolExecutionEligibilityPredicate = new DefaultToolExecutionEligibilityPredicate(); + + private RetryTemplate retryTemplate = RetryUtils.DEFAULT_RETRY_TEMPLATE; + + private ObservationRegistry observationRegistry = ObservationRegistry.NOOP; + + private Builder() { + } + + public Builder cohereApi(CohereApi cohereApi) { + this.cohereApi = cohereApi; + return this; + } + + public Builder defaultOptions(CohereChatOptions defaultOptions) { + this.defaultOptions = defaultOptions; + return this; + } + + public Builder toolCallingManager(ToolCallingManager toolCallingManager) { + this.toolCallingManager = toolCallingManager; + return this; + } + + public Builder toolExecutionEligibilityPredicate( + ToolExecutionEligibilityPredicate toolExecutionEligibilityPredicate) { + this.toolExecutionEligibilityPredicate = toolExecutionEligibilityPredicate; + return this; + } + + public Builder retryTemplate(RetryTemplate retryTemplate) { + this.retryTemplate = retryTemplate; + return this; + } + + public Builder observationRegistry(ObservationRegistry observationRegistry) { + this.observationRegistry = observationRegistry; + return this; + } + + public CohereChatModel build() { + if (this.toolCallingManager != null) { + return new CohereChatModel(this.cohereApi, this.defaultOptions, this.toolCallingManager, + this.retryTemplate, this.observationRegistry, this.toolExecutionEligibilityPredicate); + } + return new CohereChatModel(this.cohereApi, this.defaultOptions, DEFAULT_TOOL_CALLING_MANAGER, + this.retryTemplate, this.observationRegistry, this.toolExecutionEligibilityPredicate); + } + + } + +} diff --git a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/chat/CohereChatOptions.java b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/chat/CohereChatOptions.java new file mode 100644 index 00000000000..1f133b1b36b --- /dev/null +++ b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/chat/CohereChatOptions.java @@ -0,0 +1,516 @@ +package org.springframework.ai.cohere.chat; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +import org.springframework.ai.cohere.api.CohereApi; +import org.springframework.ai.cohere.api.CohereApi.ChatCompletionRequest.ResponseFormat; +import org.springframework.ai.cohere.api.CohereApi.ChatCompletionRequest.ToolChoice; +import org.springframework.ai.cohere.api.CohereApi.FunctionTool; +import org.springframework.ai.model.tool.ToolCallingChatOptions; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Options for the Cohere API. + * + * @author Ricken Bazolo + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class CohereChatOptions implements ToolCallingChatOptions { + + /** + * 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") Double temperature; + + /** + * Ensures that only the most likely tokens, with total probability mass of p, are + * considered for generation at each step. If both k and p are enabled, p acts after + * k. Defaults to 0.75. min value of 0.01, max value of 0.99. + */ + private @JsonProperty("p") Double p; + + /** + * The maximum number of tokens to generate in the chat completion. The total length + * of input tokens and generated tokens is limited by the model's context length. + */ + private @JsonProperty("max_tokens") Integer maxTokens; + + /** + * Min value of 0.0, max value of 1.0. Used to reduce repetitiveness of generated + * tokens. Similar to frequency_penalty, except that this penalty is applied equally + * to all tokens that have already appeared, regardless of their exact frequencies. + */ + private @JsonProperty("presence_penalty") Double presencePenalty; + + /** + * Nin value of 0.0, max value of 1.0. Used to reduce repetitiveness of generated + * tokens. Similar to frequency_penalty, except that this penalty is applied equally + * to all tokens that have already appeared, regardless of their exact frequencies. + */ + private @JsonProperty("frequency_penalty") Double frequencyPenalty; + + /** + * Ensures that only the top k most likely tokens are considered for generation at + * each step. When k is set to 0, k-sampling is disabled. Defaults to 0, min value of + * 0, max value of 500. + */ + private @JsonProperty("k") Integer k; + + /** + * A list of tools the model may call. Currently, only functions are supported as a + * tool. Use this to provide a list of functions the model may generate JSON inputs + * for. + */ + private @JsonProperty("tools") List tools; + + /** + * An object specifying the format that the model must output. Setting to { "type": + * "json_object" } enables JSON mode, which guarantees the message the model generates + * is valid JSON. + */ + private @JsonProperty("response_format") ResponseFormat responseFormat; + + /** + * Used to select the safety instruction inserted into the prompt. Defaults to + * CONTEXTUAL. When OFF is specified, the safety instruction will be omitted. + */ + private @JsonProperty("safety_mode") CohereApi.SafetyMode safetyMode; + + /** + * A list of up to 5 strings that the model will use to stop generation. If the model + * generates a string that matches any of the strings in the list, it will stop + * generating tokens and return the generated text up to that point not including the + * stop sequence. + */ + private @JsonProperty("stop_sequences") List stopSequences; + + /** + * If specified, the backend will make a best effort to sample tokens + * deterministically, such that repeated requests with the same seed and parameters + * should return the same result. However, determinism cannot be totally guaranteed. + */ + private @JsonProperty("seed") Integer seed; + + /** + * Defaults to false. When set to true, the log probabilities of the generated tokens + * will be included in the response. + */ + private @JsonProperty("logprobs") Boolean logprobs; + + /** + * Controls which (if any) function is called by the model. none means the model will + * not call a function and instead generates a message. auto means the model can pick + * between generating a message or calling a function. Specifying a particular + * function via {"type: "function", "function": {"name": "my_function"}} forces the + * model to call that function. none is the default when no functions are present. + * auto is the default if functions are present. Use the + * {@link CohereApi.ToolChoiceBuilder} to create + * a tool choice object. + */ + private @JsonProperty("tool_choice") ToolChoice toolChoice; + + private @JsonProperty("strict_tools") Boolean strictTools; + + /** + * Collection of {@link ToolCallback}s to be used for tool calling in the chat + * completion requests. + */ + @JsonIgnore + private List toolCallbacks = new ArrayList<>(); + + /** + * Collection of tool names to be resolved at runtime and used for tool calling in the + * chat completion requests. + */ + @JsonIgnore + private Set toolNames = new HashSet<>(); + + /** + * Whether to enable the tool execution lifecycle internally in ChatModel. + */ + @JsonIgnore + private Boolean internalToolExecutionEnabled; + + @JsonIgnore + private Map toolContext = new HashMap<>(); + + public CohereApi.SafetyMode getSafetyMode() { + return this.safetyMode; + } + + public void setSafetyMode(CohereApi.SafetyMode safetyMode) { + this.safetyMode = safetyMode; + } + + public Integer getSeed() { + return this.seed; + } + + public void setSeed(Integer seed) { + this.seed = seed; + } + + public Boolean getLogprobs() { + return this.logprobs; + } + + public void setLogprobs(Boolean logprobs) { + this.logprobs = logprobs; + } + + public Boolean getStrictTools() { + return this.strictTools; + } + + public void setStrictTools(Boolean strictTools) { + this.strictTools = strictTools; + } + + public Double getP() { + return this.p; + } + + public void setP(Double p) { + this.p = p; + } + + @Override + public String getModel() { + return this.model; + } + + public void setModel(String model) { + this.model = model; + } + + @Override + public Integer getMaxTokens() { + return this.maxTokens; + } + + public void setMaxTokens(Integer maxTokens) { + this.maxTokens = maxTokens; + } + + public ResponseFormat getResponseFormat() { + return this.responseFormat; + } + + public void setResponseFormat(ResponseFormat responseFormat) { + this.responseFormat = responseFormat; + } + + @Override + @JsonIgnore + public List getStopSequences() { + return getStop(); + } + + @JsonIgnore + public void setStopSequences(List stopSequences) { + setStop(stopSequences); + } + + public List getStop() { + return this.stopSequences; + } + + public void setStop(List stop) { + this.stopSequences = stop; + } + + public List getTools() { + return this.tools; + } + + public void setTools(List tools) { + this.tools = tools; + } + + public ToolChoice getToolChoice() { + return this.toolChoice; + } + + public void setToolChoice(ToolChoice toolChoice) { + this.toolChoice = toolChoice; + } + + @Override + public Double getTemperature() { + return this.temperature; + } + + public void setTemperature(Double temperature) { + this.temperature = temperature; + } + + @Override + public Double getTopP() { + return getP(); + } + + public void setTopP(Double topP) { + setP(topP); + } + + @Override + public Double getFrequencyPenalty() { + return this.frequencyPenalty; + } + + public void setFrequencyPenalty(Double frequencyPenalty) { + this.frequencyPenalty = frequencyPenalty; + } + + @Override + public Double getPresencePenalty() { + return this.presencePenalty; + } + + public void setPresencePenalty(Double presencePenalty) { + this.presencePenalty = presencePenalty; + } + + @Override + public CohereChatOptions copy() { + return fromOptions(this); + } + + @Override + @JsonIgnore + public List getToolCallbacks() { + return this.toolCallbacks; + } + + @Override + @JsonIgnore + public void setToolCallbacks(List toolCallbacks) { + Assert.notNull(toolCallbacks, "toolCallbacks cannot be null"); + Assert.noNullElements(toolCallbacks, "toolCallbacks cannot contain null elements"); + this.toolCallbacks = toolCallbacks; + } + + @Override + @JsonIgnore + public Set getToolNames() { + return this.toolNames; + } + + @Override + @JsonIgnore + public void setToolNames(Set toolNames) { + Assert.notNull(toolNames, "toolNames cannot be null"); + Assert.noNullElements(toolNames, "toolNames cannot contain null elements"); + toolNames.forEach(tool -> Assert.hasText(tool, "toolNames cannot contain empty elements")); + this.toolNames = toolNames; + } + + @Override + @Nullable + @JsonIgnore + public Boolean getInternalToolExecutionEnabled() { + return this.internalToolExecutionEnabled; + } + + @Override + @JsonIgnore + public void setInternalToolExecutionEnabled(@Nullable Boolean internalToolExecutionEnabled) { + this.internalToolExecutionEnabled = internalToolExecutionEnabled; + } + + @Override + @JsonIgnore + public Integer getTopK() { + return this.k; + } + + public void setTopK(Integer k) { + this.k = k; + } + + @Override + @JsonIgnore + public Map getToolContext() { + return this.toolContext; + } + + @Override + @JsonIgnore + public void setToolContext(Map toolContext) { + this.toolContext = toolContext; + } + + public static Builder builder() { + return new Builder(); + } + + public static CohereChatOptions fromOptions(CohereChatOptions fromOptions) { + return CohereChatOptions.builder() + .model(fromOptions.getModel()) + .temperature(fromOptions.getTemperature()) + .maxTokens(fromOptions.getMaxTokens()) + .topP(fromOptions.getTopP()) + .frequencyPenalty(fromOptions.getFrequencyPenalty()) + .presencePenalty(fromOptions.getPresencePenalty()) + .topK(fromOptions.getTopK()) + .tools(fromOptions.getTools()) + .responseFormat(fromOptions.getResponseFormat()) + .safetyMode(fromOptions.getSafetyMode()) + .stop(fromOptions.getStopSequences()) + .seed(fromOptions.getSeed()) + .logprobs(fromOptions.getLogprobs()) + .toolChoice(fromOptions.getToolChoice()) + .strictTools(fromOptions.getStrictTools()) + .toolCallbacks(fromOptions.getToolCallbacks()) + .toolNames(fromOptions.getToolNames()) + .internalToolExecutionEnabled(fromOptions.getInternalToolExecutionEnabled()).build(); + } + + public static class Builder { + + private final CohereChatOptions options = new CohereChatOptions(); + + public CohereChatOptions build() { + return this.options; + } + + public Builder model(String model) { + this.options.setModel(model); + return this; + } + + public Builder model(CohereApi.ChatModel chatModel) { + this.options.setModel(chatModel.getName()); + return this; + } + + public Builder safetyMode(CohereApi.SafetyMode safetyMode) { + this.options.setSafetyMode(safetyMode); + return this; + } + + public Builder logprobs(Boolean logprobs) { + this.options.setLogprobs(logprobs); + return this; + } + + public Builder toolContext(Map toolContext) { + if (this.options.toolContext == null) { + this.options.toolContext = toolContext; + } + else { + this.options.toolContext.putAll(toolContext); + } + return this; + } + + public Builder maxTokens(Integer maxTokens) { + this.options.setMaxTokens(maxTokens); + return this; + } + + public Builder seed(Integer seed) { + this.options.setSeed(seed); + return this; + } + + public Builder stop(List stop) { + this.options.setStop(stop); + return this; + } + + public Builder frequencyPenalty(Double frequencyPenalty) { + this.options.frequencyPenalty = frequencyPenalty; + return this; + } + + public Builder presencePenalty(Double presencePenalty) { + this.options.presencePenalty = presencePenalty; + return this; + } + + public Builder temperature(Double temperature) { + this.options.setTemperature(temperature); + return this; + } + + public Builder topP(Double topP) { + this.options.setTopP(topP); + return this; + } + + public Builder topK(Integer k) { + this.options.setTopK(k); + return this; + } + + public Builder responseFormat(ResponseFormat responseFormat) { + this.options.responseFormat = responseFormat; + return this; + } + + public Builder tools(List tools) { + this.options.tools = tools; + return this; + } + + public Builder strictTools(Boolean strictTools) { + this.options.setStrictTools(strictTools); + return this; + } + + public Builder toolChoice(ToolChoice toolChoice) { + this.options.toolChoice = toolChoice; + return this; + } + + public Builder toolCallbacks(List toolCallbacks) { + this.options.setToolCallbacks(toolCallbacks); + return this; + } + + public Builder toolCallbacks(ToolCallback... toolCallbacks) { + Assert.notNull(toolCallbacks, "toolCallbacks cannot be null"); + this.options.toolCallbacks.addAll(Arrays.asList(toolCallbacks)); + return this; + } + + public Builder toolNames(Set toolNames) { + Assert.notNull(toolNames, "toolNames cannot be null"); + this.options.setToolNames(toolNames); + return this; + } + + public Builder toolNames(String... toolNames) { + Assert.notNull(toolNames, "toolNames cannot be null"); + this.options.toolNames.addAll(Set.of(toolNames)); + return this; + } + + public Builder internalToolExecutionEnabled(@Nullable Boolean internalToolExecutionEnabled) { + this.options.setInternalToolExecutionEnabled(internalToolExecutionEnabled); + return this; + } + + } + +} diff --git a/models/spring-ai-cohere/src/main/resources/META-INF/spring/aot.factories b/models/spring-ai-cohere/src/main/resources/META-INF/spring/aot.factories new file mode 100644 index 00000000000..e6ba5bc93af --- /dev/null +++ b/models/spring-ai-cohere/src/main/resources/META-INF/spring/aot.factories @@ -0,0 +1,2 @@ +org.springframework.aot.hint.RuntimeHintsRegistrar=\ + org.springframework.ai.cohere.aot.CohereRuntimeHints diff --git a/pom.xml b/pom.xml index 9695f90f231..c6f5961a314 100644 --- a/pom.xml +++ b/pom.xml @@ -118,6 +118,7 @@ auto-configurations/models/spring-ai-autoconfigure-model-google-genai auto-configurations/models/spring-ai-autoconfigure-model-zhipuai auto-configurations/models/spring-ai-autoconfigure-model-deepseek + auto-configurations/models/spring-ai-autoconfigure-model-cohere auto-configurations/mcp/spring-ai-autoconfigure-mcp-client-common auto-configurations/mcp/spring-ai-autoconfigure-mcp-client-httpclient @@ -189,6 +190,7 @@ models/spring-ai-google-genai-embedding models/spring-ai-zhipuai models/spring-ai-deepseek + models/spring-ai-cohere spring-ai-spring-boot-starters/spring-ai-starter-model-anthropic spring-ai-spring-boot-starters/spring-ai-starter-model-azure-openai @@ -210,6 +212,7 @@ spring-ai-spring-boot-starters/spring-ai-starter-model-vertex-ai-gemini spring-ai-spring-boot-starters/spring-ai-starter-model-zhipuai spring-ai-spring-boot-starters/spring-ai-starter-model-deepseek + spring-ai-spring-boot-starters/spring-ai-starter-model-cohere spring-ai-spring-boot-starters/spring-ai-starter-model-chat-memory spring-ai-spring-boot-starters/spring-ai-starter-model-chat-memory-repository-cassandra @@ -842,6 +845,7 @@ org.springframework.ai.vertexai.embedding/**/*IT.java org.springframework.ai.vertexai.gemini/**/*IT.java org.springframework.ai.zhipuai/**/*IT.java + org.springframework.ai.cohere/**/*IT.java org.springframework.ai.vectorstore**/CosmosDB**IT.java diff --git a/spring-ai-bom/pom.xml b/spring-ai-bom/pom.xml index 53b3d6aa0d5..9b046e531b0 100644 --- a/spring-ai-bom/pom.xml +++ b/spring-ai-bom/pom.xml @@ -353,6 +353,12 @@ ${project.version} + + org.springframework.ai + spring-ai-cohere + ${project.version} + + @@ -717,6 +723,12 @@ ${project.version} + + org.springframework.ai + spring-ai-autoconfigure-model-cohere + ${project.version} + + org.springframework.ai @@ -1084,6 +1096,12 @@ ${project.version} + + org.springframework.ai + spring-ai-starter-model-cohere + ${project.version} + + diff --git a/spring-ai-model/src/main/java/org/springframework/ai/model/SpringAIModels.java b/spring-ai-model/src/main/java/org/springframework/ai/model/SpringAIModels.java index 19f8a8a3258..53361bba703 100644 --- a/spring-ai-model/src/main/java/org/springframework/ai/model/SpringAIModels.java +++ b/spring-ai-model/src/main/java/org/springframework/ai/model/SpringAIModels.java @@ -60,4 +60,6 @@ private SpringAIModels() { public static final String ELEVEN_LABS = "elevenlabs"; + public static final String COHERE = "cohere"; + } diff --git a/spring-ai-spring-boot-starters/spring-ai-starter-model-cohere/pom.xml b/spring-ai-spring-boot-starters/spring-ai-starter-model-cohere/pom.xml new file mode 100644 index 00000000000..5c11b7084be --- /dev/null +++ b/spring-ai-spring-boot-starters/spring-ai-starter-model-cohere/pom.xml @@ -0,0 +1,54 @@ + + + 4.0.0 + + org.springframework.ai + spring-ai-parent + 1.1.0-SNAPSHOT + ../../pom.xml + + spring-ai-starter-model-cohere + jar + Spring AI Starter - Cohere + Spring AI Cohere Spring Boot Starter + 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-autoconfigure-model-cohere + ${project.parent.version} + + + + org.springframework.ai + spring-ai-cohere + ${project.parent.version} + + + + org.springframework.ai + spring-ai-autoconfigure-model-chat-client + ${project.parent.version} + + + + org.springframework.ai + spring-ai-autoconfigure-model-chat-memory + ${project.parent.version} + + + + From f1e2fd921df8dd5e074b35535c4f2f1109d2bbf6 Mon Sep 17 00:00:00 2001 From: Ricken Bazolo Date: Tue, 19 Aug 2025 13:35:38 +0200 Subject: [PATCH 02/18] added cohere support part 1 - chat model Signed-off-by: ricken07 --- .../java/org/springframework/ai/cohere/api/CohereApi.java | 2 +- .../ai/observation/conventions/AiProvider.java | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/api/CohereApi.java b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/api/CohereApi.java index 2ed6a5920ea..e8db8697e8e 100644 --- a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/api/CohereApi.java +++ b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/api/CohereApi.java @@ -34,7 +34,7 @@ */ public class CohereApi { - public static final String PROVIDER_NAME = AiProvider.MISTRAL_AI.value(); + public static final String PROVIDER_NAME = AiProvider.COHERE.value(); private static final String DEFAULT_BASE_URL = "https://api.cohere.com"; diff --git a/spring-ai-commons/src/main/java/org/springframework/ai/observation/conventions/AiProvider.java b/spring-ai-commons/src/main/java/org/springframework/ai/observation/conventions/AiProvider.java index 88105725a69..7f22cf31f5e 100644 --- a/spring-ai-commons/src/main/java/org/springframework/ai/observation/conventions/AiProvider.java +++ b/spring-ai-commons/src/main/java/org/springframework/ai/observation/conventions/AiProvider.java @@ -98,7 +98,11 @@ public enum AiProvider { /** * AI system provided by Zhipuai. */ - ZHIPUAI("zhipuai"); + ZHIPUAI("zhipuai"), + /** + * AI system provided by Cohere. + */ + COHERE("cohere"); private final String value; From 827d2cdf357626dd8e077afd425242791c2c58df Mon Sep 17 00:00:00 2001 From: Ricken Bazolo Date: Tue, 19 Aug 2025 13:35:38 +0200 Subject: [PATCH 03/18] added cohere support part 1 - chat model Signed-off-by: ricken07 --- .../ai/cohere/api/CohereApi.java | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/api/CohereApi.java b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/api/CohereApi.java index e8db8697e8e..0529d7126c7 100644 --- a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/api/CohereApi.java +++ b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/api/CohereApi.java @@ -1,6 +1,7 @@ package org.springframework.ai.cohere.api; import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import org.springframework.ai.model.ChatModelDescription; @@ -14,9 +15,12 @@ import org.springframework.web.client.ResponseErrorHandler; import org.springframework.web.client.RestClient; import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; import java.util.List; import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Consumer; import java.util.function.Predicate; @@ -791,5 +795,94 @@ public ResponseEntity chatCompletionEntity(ChatCompletionRequest .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 stream property to true."); + + AtomicBoolean isInsideTool = new AtomicBoolean(false); + + return this.webClient.post() + .uri("/v2/chat/") + .body(Mono.just(chatRequest), ChatCompletionRequest.class) + .retrieve() + .bodyToFlux(String.class) + .takeUntil(SSE_DONE_PREDICATE) + .filter(SSE_DONE_PREDICATE.negate()) + .map(content -> ModelOptionsUtils.jsonToObject(content, ChatCompletionChunk.class)) + .map(chunk -> { + if (this.chunkMerger.isStreamingToolFunctionCall(chunk)) { + isInsideTool.set(true); + } + return chunk; + }) + .windowUntil(chunk -> { + if (isInsideTool.get() && this.chunkMerger.isStreamingToolFunctionCallFinish(chunk)) { + isInsideTool.set(false); + return true; + } + return !isInsideTool.get(); + }) + .concatMapIterable(window -> { + Mono mono1 = window.reduce( + new ChatCompletionChunk(null, null, null, null, null, null), + (previous, current) -> this.chunkMerger.merge(previous, current)); + return List.of(mono1); + }) + .flatMap(mono -> mono); + } + + /** + * 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. + * @param usage usage metrics for the chat completion. + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + @JsonIgnoreProperties(ignoreUnknown = true) + 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, + @JsonProperty("usage") Usage usage) { + // @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. + * @param logprobs Log probability information for the choice. + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + @JsonIgnoreProperties(ignoreUnknown = true) + public record ChunkChoice( + // @formatter:off + @JsonProperty("index") Integer index, + @JsonProperty("delta") ChatCompletionMessage delta, + @JsonProperty("finish_reason") ChatCompletionFinishReason finishReason, + @JsonProperty("logprobs") LogProbs logprobs) { + // @formatter:on + } + + } + } From 24a148ed36903eaba20b20981cfd98b8645a3ab5 Mon Sep 17 00:00:00 2001 From: Ricken Bazolo Date: Sun, 16 Nov 2025 16:01:35 +0100 Subject: [PATCH 04/18] added cohere support :: update code and fix cohere api chat completion test Signed-off-by: ricken07 --- .../pom.xml | 2 +- .../CohereChatAutoConfiguration.java | 42 ++- .../autoconfigure/CohereChatProperties.java | 16 +- .../CohereAutoConfigurationIT.java | 15 +- .../CohereModelConfigurationTests.java | 22 +- .../autoconfigure/CoherePropertiesTests.java | 82 ++--- models/spring-ai-cohere/pom.xml | 18 +- .../ai/cohere/aot/CohereRuntimeHints.java | 16 + .../ai/cohere/api/CohereApi.java | 326 +++++++++++------- .../ai/cohere/chat/CohereChatModel.java | 184 +++++----- .../ai/cohere/chat/CohereChatOptions.java | 56 +-- .../ai/cohere/CohereTestConfiguration.java | 54 +++ .../cohere/aot/CohereRuntimeHintsTests.java | 242 +++++++++++++ .../ai/cohere/api/CohereApiIT.java | 53 +++ .../ai/cohere/testutils/AbstractIT.java | 100 ++++++ .../observation/conventions/AiProvider.java | 11 +- .../spring-ai-starter-model-cohere/pom.xml | 2 +- 17 files changed, 916 insertions(+), 325 deletions(-) create mode 100644 models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/CohereTestConfiguration.java create mode 100644 models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/aot/CohereRuntimeHintsTests.java create mode 100644 models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/api/CohereApiIT.java create mode 100644 models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/testutils/AbstractIT.java diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-cohere/pom.xml b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/pom.xml index 8424c3b124b..896d5047923 100644 --- a/auto-configurations/models/spring-ai-autoconfigure-model-cohere/pom.xml +++ b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/pom.xml @@ -6,7 +6,7 @@ org.springframework.ai spring-ai-parent - 1.1.0-SNAPSHOT + 2.0.0-SNAPSHOT ../../../pom.xml spring-ai-autoconfigure-model-cohere diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/java/org/springframework/ai/cohere/autoconfigure/CohereChatAutoConfiguration.java b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/java/org/springframework/ai/cohere/autoconfigure/CohereChatAutoConfiguration.java index b703d63dbe3..1b9a7f3df0c 100644 --- a/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/java/org/springframework/ai/cohere/autoconfigure/CohereChatAutoConfiguration.java +++ b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/java/org/springframework/ai/cohere/autoconfigure/CohereChatAutoConfiguration.java @@ -44,27 +44,26 @@ public class CohereChatAutoConfiguration { @Bean @ConditionalOnMissingBean - public CohereChatModel chereChatModel(CohereCommonProperties commonProperties, - CohereChatProperties chatProperties, ObjectProvider restClientBuilderProvider, - ObjectProvider webClientBuilderProvider, ToolCallingManager toolCallingManager, - RetryTemplate retryTemplate, ResponseErrorHandler responseErrorHandler, - ObjectProvider observationRegistry, - ObjectProvider observationConvention, - ObjectProvider cohereToolExecutionEligibilityPredicate) { - var cohereApi = cohereApi(chatProperties.getApiKey(), commonProperties.getApiKey(), - chatProperties.getBaseUrl(), commonProperties.getBaseUrl(), - restClientBuilderProvider.getIfAvailable(RestClient::builder), + public CohereChatModel chereChatModel(CohereCommonProperties commonProperties, CohereChatProperties chatProperties, + ObjectProvider restClientBuilderProvider, + ObjectProvider webClientBuilderProvider, ToolCallingManager toolCallingManager, + RetryTemplate retryTemplate, ResponseErrorHandler responseErrorHandler, + ObjectProvider observationRegistry, + ObjectProvider observationConvention, + ObjectProvider cohereToolExecutionEligibilityPredicate) { + var cohereApi = cohereApi(chatProperties.getApiKey(), commonProperties.getApiKey(), chatProperties.getBaseUrl(), + commonProperties.getBaseUrl(), restClientBuilderProvider.getIfAvailable(RestClient::builder), webClientBuilderProvider.getIfAvailable(WebClient::builder), responseErrorHandler); var chatModel = CohereChatModel.builder() - .cohereApi(cohereApi) - .defaultOptions(chatProperties.getOptions()) - .toolCallingManager(toolCallingManager) - .toolExecutionEligibilityPredicate(cohereToolExecutionEligibilityPredicate - .getIfUnique(DefaultToolExecutionEligibilityPredicate::new)) - .retryTemplate(retryTemplate) - .observationRegistry(observationRegistry.getIfUnique(() -> ObservationRegistry.NOOP)) - .build(); + .cohereApi(cohereApi) + .defaultOptions(chatProperties.getOptions()) + .toolCallingManager(toolCallingManager) + .toolExecutionEligibilityPredicate( + cohereToolExecutionEligibilityPredicate.getIfUnique(DefaultToolExecutionEligibilityPredicate::new)) + .retryTemplate(retryTemplate) + .observationRegistry(observationRegistry.getIfUnique(() -> ObservationRegistry.NOOP)) + .build(); observationConvention.ifAvailable(chatModel::setObservationConvention); @@ -72,8 +71,8 @@ public CohereChatModel chereChatModel(CohereCommonProperties commonProperties, } private CohereApi cohereApi(String apiKey, String commonApiKey, String baseUrl, String commonBaseUrl, - RestClient.Builder restClientBuilder, WebClient.Builder webClientBuilder, - ResponseErrorHandler responseErrorHandler) { + RestClient.Builder restClientBuilder, WebClient.Builder webClientBuilder, + ResponseErrorHandler responseErrorHandler) { var resolvedApiKey = StringUtils.hasText(apiKey) ? apiKey : commonApiKey; var resoledBaseUrl = StringUtils.hasText(baseUrl) ? baseUrl : commonBaseUrl; @@ -81,8 +80,7 @@ private CohereApi cohereApi(String apiKey, String commonApiKey, String baseUrl, Assert.hasText(resolvedApiKey, "Cohere API key must be set"); Assert.hasText(resoledBaseUrl, "Cohere base URL must be set"); - return new CohereApi(resoledBaseUrl, resolvedApiKey, restClientBuilder, webClientBuilder, - responseErrorHandler); + return new CohereApi(resoledBaseUrl, resolvedApiKey, restClientBuilder, webClientBuilder, responseErrorHandler); } } diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/java/org/springframework/ai/cohere/autoconfigure/CohereChatProperties.java b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/java/org/springframework/ai/cohere/autoconfigure/CohereChatProperties.java index 14c1b9c55d8..f0ccfc6882b 100644 --- a/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/java/org/springframework/ai/cohere/autoconfigure/CohereChatProperties.java +++ b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/java/org/springframework/ai/cohere/autoconfigure/CohereChatProperties.java @@ -15,7 +15,7 @@ public class CohereChatProperties extends CohereParentProperties { public static final String CONFIG_PREFIX = "spring.ai.cohere.chat"; - public static final String DEFAULT_CHAT_MODEL = CohereApi.ChatModel.COMMAND_R7B.getValue(); + public static final String DEFAULT_CHAT_MODEL = CohereApi.ChatModel.COMMAND_A_R7B.getValue(); private static final Double DEFAULT_TEMPERATURE = 0.3; @@ -23,13 +23,13 @@ public class CohereChatProperties extends CohereParentProperties { @NestedConfigurationProperty private CohereChatOptions options = CohereChatOptions.builder() - .model(DEFAULT_CHAT_MODEL) - .temperature(DEFAULT_TEMPERATURE) - .topP(DEFAULT_TOP_P) - .presencePenalty(0.0) - .frequencyPenalty(0.0) - .logprobs(false) - .build(); + .model(DEFAULT_CHAT_MODEL) + .temperature(DEFAULT_TEMPERATURE) + .topP(DEFAULT_TOP_P) + .presencePenalty(0.0) + .frequencyPenalty(0.0) + .logprobs(false) + .build(); public CohereChatProperties() { super.setBaseUrl(CohereCommonProperties.DEFAULT_BASE_URL); diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/test/java/org/springframework/ai/cohere/autoconfigure/CohereAutoConfigurationIT.java b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/test/java/org/springframework/ai/cohere/autoconfigure/CohereAutoConfigurationIT.java index 1d506793612..63b6bccbaaf 100644 --- a/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/test/java/org/springframework/ai/cohere/autoconfigure/CohereAutoConfigurationIT.java +++ b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/test/java/org/springframework/ai/cohere/autoconfigure/CohereAutoConfigurationIT.java @@ -19,17 +19,16 @@ public class CohereAutoConfigurationIT { private static final Log logger = LogFactory.getLog(CohereAutoConfigurationIT.class); private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() - .withPropertyValues("spring.ai.cohere.apiKey=" + System.getenv("COHERE_API_KEY")); + .withPropertyValues("spring.ai.cohere.apiKey=" + System.getenv("COHERE_API_KEY")); @Test void generate() { - this.contextRunner.withConfiguration(AutoConfigurations.of(CohereChatAutoConfiguration.class)) - .run(context -> { - CohereChatModel chatModel = context.getBean(CohereChatModel.class); - String response = chatModel.call("Hello"); - assertThat(response).isNotEmpty(); - logger.info("Response: " + response); - }); + this.contextRunner.withConfiguration(AutoConfigurations.of(CohereChatAutoConfiguration.class)).run(context -> { + CohereChatModel chatModel = context.getBean(CohereChatModel.class); + String response = chatModel.call("Hello"); + assertThat(response).isNotEmpty(); + logger.info("Response: " + response); + }); } } diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/test/java/org/springframework/ai/cohere/autoconfigure/CohereModelConfigurationTests.java b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/test/java/org/springframework/ai/cohere/autoconfigure/CohereModelConfigurationTests.java index 7ba664a3075..8c457fc4f94 100644 --- a/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/test/java/org/springframework/ai/cohere/autoconfigure/CohereModelConfigurationTests.java +++ b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/test/java/org/springframework/ai/cohere/autoconfigure/CohereModelConfigurationTests.java @@ -15,21 +15,21 @@ public class CohereModelConfigurationTests { private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() - .withPropertyValues("spring.ai.cohere.apiKey=" + System.getenv("COHERE_API_KEY")); + .withPropertyValues("spring.ai.cohere.apiKey=" + System.getenv("COHERE_API_KEY")); @Test void chatModelActivation() { - this.contextRunner.withConfiguration(AutoConfigurations.of(CohereChatAutoConfiguration.class)) - .run(context -> { - assertThat(context.getBeansOfType(CohereChatProperties.class)).isNotEmpty(); - assertThat(context.getBeansOfType(CohereChatModel.class)).isNotEmpty(); - }); + this.contextRunner.withConfiguration(AutoConfigurations.of(CohereChatAutoConfiguration.class)).run(context -> { + assertThat(context.getBeansOfType(CohereChatProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(CohereChatModel.class)).isNotEmpty(); + }); this.contextRunner.withConfiguration(AutoConfigurations.of(CohereChatAutoConfiguration.class)) - .withPropertyValues("spring.ai.model.chat=none") - .run(context -> { - assertThat(context.getBeansOfType(CohereChatProperties.class)).isEmpty(); - assertThat(context.getBeansOfType(CohereChatModel.class)).isEmpty(); - }); + .withPropertyValues("spring.ai.model.chat=none") + .run(context -> { + assertThat(context.getBeansOfType(CohereChatProperties.class)).isEmpty(); + assertThat(context.getBeansOfType(CohereChatModel.class)).isEmpty(); + }); } + } diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/test/java/org/springframework/ai/cohere/autoconfigure/CoherePropertiesTests.java b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/test/java/org/springframework/ai/cohere/autoconfigure/CoherePropertiesTests.java index 7068ab404b1..0b204ef4163 100644 --- a/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/test/java/org/springframework/ai/cohere/autoconfigure/CoherePropertiesTests.java +++ b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/test/java/org/springframework/ai/cohere/autoconfigure/CoherePropertiesTests.java @@ -17,47 +17,47 @@ public class CoherePropertiesTests { @Test public void chatOptionsTest() { - new ApplicationContextRunner().withPropertyValues( - "spring.ai.cohere.base-url=TEST_BASE_URL", - "spring.ai.cohere.api-key=abc123", - "spring.ai.cohere.chat.options.tools[0].function.name=myFunction1", - "spring.ai.cohere.chat.options.tools[0].function.description=function description", - "spring.ai.cohere.chat.options.tools[0].function.jsonSchema=" + """ - { - "type": "object", - "properties": { - "location": { - "type": "string", - "description": "The city and state e.g. San Francisco, CA" + new ApplicationContextRunner() + .withPropertyValues("spring.ai.cohere.base-url=TEST_BASE_URL", "spring.ai.cohere.api-key=abc123", + "spring.ai.cohere.chat.options.tools[0].function.name=myFunction1", + "spring.ai.cohere.chat.options.tools[0].function.description=function description", + "spring.ai.cohere.chat.options.tools[0].function.jsonSchema=" + """ + { + "type": "object", + "properties": { + "location": { + "type": "string", + "description": "The city and state e.g. San Francisco, CA" + }, + "lat": { + "type": "number", + "description": "The city latitude" + }, + "lon": { + "type": "number", + "description": "The city longitude" + }, + "unit": { + "type": "string", + "enum": ["c", "f"] + } }, - "lat": { - "type": "number", - "description": "The city latitude" - }, - "lon": { - "type": "number", - "description": "The city longitude" - }, - "unit": { - "type": "string", - "enum": ["c", "f"] - } - }, - "required": ["location", "lat", "lon", "unit"] - } - """) - .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, - RestClientAutoConfiguration.class, CohereChatAutoConfiguration.class)) - .run(context -> { - - var chatProperties = context.getBean(CohereChatProperties.class); - - var tool = chatProperties.getOptions().getTools().get(0); - assertThat(tool.getType()).isEqualTo(CohereApi.FunctionTool.Type.FUNCTION); - var function = tool.getFunction(); - assertThat(function.getName()).isEqualTo("myFunction1"); - assertThat(function.getDescription()).isEqualTo("function description"); - assertThat(function.getParameters()).isNotEmpty(); - }); + "required": ["location", "lat", "lon", "unit"] + } + """) + .withConfiguration(AutoConfigurations.of(SpringAiRetryAutoConfiguration.class, + RestClientAutoConfiguration.class, CohereChatAutoConfiguration.class)) + .run(context -> { + + var chatProperties = context.getBean(CohereChatProperties.class); + + var tool = chatProperties.getOptions().getTools().get(0); + assertThat(tool.getType()).isEqualTo(CohereApi.FunctionTool.Type.FUNCTION); + var function = tool.getFunction(); + assertThat(function.getName()).isEqualTo("myFunction1"); + assertThat(function.getDescription()).isEqualTo("function description"); + assertThat(function.getParameters()).isNotEmpty(); + }); } + } diff --git a/models/spring-ai-cohere/pom.xml b/models/spring-ai-cohere/pom.xml index 1e549082161..4b4d97dc4c8 100644 --- a/models/spring-ai-cohere/pom.xml +++ b/models/spring-ai-cohere/pom.xml @@ -1,4 +1,20 @@ + + @@ -6,7 +22,7 @@ org.springframework.ai spring-ai-parent - 1.1.0-SNAPSHOT + 2.0.0-SNAPSHOT ../../pom.xml spring-ai-cohere diff --git a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/aot/CohereRuntimeHints.java b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/aot/CohereRuntimeHints.java index 591e0096c59..cf159ca27bc 100644 --- a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/aot/CohereRuntimeHints.java +++ b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/aot/CohereRuntimeHints.java @@ -1,3 +1,19 @@ +/* + * 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.cohere.aot; import org.springframework.aot.hint.MemberCategory; diff --git a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/api/CohereApi.java b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/api/CohereApi.java index 0529d7126c7..cdf478be581 100644 --- a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/api/CohereApi.java +++ b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/api/CohereApi.java @@ -1,3 +1,19 @@ +/* + * Copyright 2023-2025 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.cohere.api; import com.fasterxml.jackson.annotation.JsonIgnore; @@ -26,8 +42,8 @@ /** * Java Client library for Cohere Platform. Provides implementation for the - * Chat - * and Chat Stream + * Chat and + * Chat Stream * Embedding API. *

* Implements Synchronous and Streaming chat completion and supports latest @@ -64,7 +80,8 @@ public CohereApi(String cohereApiKey) { * @param cohereApiKey Cohere api Key. */ public CohereApi(String baseUrl, String cohereApiKey) { - this(baseUrl, cohereApiKey, RestClient.builder(), WebClient.builder(), RetryUtils.DEFAULT_RESPONSE_ERROR_HANDLER); + this(baseUrl, cohereApiKey, RestClient.builder(), WebClient.builder(), + RetryUtils.DEFAULT_RESPONSE_ERROR_HANDLER); } /** @@ -74,8 +91,8 @@ public CohereApi(String baseUrl, String cohereApiKey) { * @param restClientBuilder RestClient builder. * @param responseErrorHandler Response error handler. */ - public CohereApi(String baseUrl, String cohereApiKey, RestClient.Builder restClientBuilder, WebClient.Builder webClientBuilder, - ResponseErrorHandler responseErrorHandler) { + public CohereApi(String baseUrl, String cohereApiKey, RestClient.Builder restClientBuilder, + WebClient.Builder webClientBuilder, ResponseErrorHandler responseErrorHandler) { Consumer jsonContentHeaders = headers -> { headers.setBearerAuth(cohereApiKey); @@ -83,26 +100,33 @@ public CohereApi(String baseUrl, String cohereApiKey, RestClient.Builder restCli }; this.restClient = restClientBuilder.baseUrl(baseUrl) - .defaultHeaders(jsonContentHeaders) - .defaultStatusHandler(responseErrorHandler) - .build(); + .defaultHeaders(jsonContentHeaders) + .defaultStatusHandler(responseErrorHandler) + .build(); this.webClient = webClientBuilder.clone().baseUrl(baseUrl).defaultHeaders(jsonContentHeaders).build(); } /** - * List of well-known Cohere chat - * models. + * List of well-known Cohere chat models. * - *

- * Cohere provides Command family of models includes: Command A, Command R7B, Command - * R+, Command R, and Command. + * @see Cohere Models Overview */ public enum ChatModel implements ChatModelDescription { - COMMAND_A("command-a-03-2025"), COMMAND_R7B("command-r7b-12-2024"), - COMMAND_R_PLUS_08_2024("command-r-plus-08-2024"), COMMAND_R_PLUS("command-r-plus"), COMMAND_R("command-r"), - COMMAND_03_2024("command-r-03-2024"); + COMMAND_A("command-a-03-2025"), + + COMMAND_A_REASONING("command-a-reasoning-08-2025"), + + COMMAND_A_TRANSLATE("command-a-translate-08-2025"), + + COMMAND_A_VISION("command-a-vision-07-2025"), + + COMMAND_A_R7B("command-r7b-12-2024"), + + COMMAND_R_PLUS("command-r-plus-08-2024"), + + COMMAND_R("command-r-08-2024"); private final String value; @@ -135,8 +159,8 @@ public record Usage(@JsonProperty("billedUnits") BilledUnits billedUnits, @JsonP * @param classifications The number of billed classifications units. */ public record BilledUnits(@JsonProperty("input_tokens") Integer inputTokens, - @JsonProperty("output_tokens") Integer outputTokens, @JsonProperty("search_units") Double searchUnits, - @JsonProperty("classifications") Double classifications) { + @JsonProperty("output_tokens") Integer outputTokens, @JsonProperty("search_units") Double searchUnits, + @JsonProperty("classifications") Double classifications) { } /** @@ -146,7 +170,7 @@ public record BilledUnits(@JsonProperty("input_tokens") Integer inputTokens, * @param outputTokens The number of tokens produced by the model. */ public record Tokens(@JsonProperty("input_tokens") Integer inputTokens, - @JsonProperty("output_tokens") Integer outputTokens) { + @JsonProperty("output_tokens") Integer outputTokens) { } } @@ -212,17 +236,17 @@ public record Tokens(@JsonProperty("input_tokens") Integer inputTokens, */ @JsonInclude(JsonInclude.Include.NON_NULL) public record ChatCompletionRequest(@JsonProperty("model") String model, - @JsonProperty("messages") List messages, - @JsonProperty("tools") List tools, @JsonProperty("documents") List documents, - @JsonProperty("citation_options") CitationOptions citationOptions, - @JsonProperty("response_format") ResponseFormat responseFormat, - @JsonProperty("safety_mode") SafetyMode safetyMode, @JsonProperty("max_tokens") Integer maxTokens, - @JsonProperty("stop_sequences") List stopSequences, @JsonProperty("temperature") Double temperature, - @JsonProperty("seed") Integer seed, @JsonProperty("frequency_penalty") Double frequencyPenalty, - @JsonProperty("stream") Boolean stream, @JsonProperty("k") Integer k, @JsonProperty("p") Double p, - @JsonProperty("logprobs") Boolean logprobs, @JsonProperty("tool_choice") ToolChoice toolChoice, - @JsonProperty("strict_tools") Boolean strictTools, - @JsonProperty("presence_penalty") Double presencePenalty) { + @JsonProperty("messages") List messages, + @JsonProperty("tools") List tools, @JsonProperty("documents") List documents, + @JsonProperty("citation_options") CitationOptions citationOptions, + @JsonProperty("response_format") ResponseFormat responseFormat, + @JsonProperty("safety_mode") SafetyMode safetyMode, @JsonProperty("max_tokens") Integer maxTokens, + @JsonProperty("stop_sequences") List stopSequences, @JsonProperty("temperature") Double temperature, + @JsonProperty("seed") Integer seed, @JsonProperty("frequency_penalty") Double frequencyPenalty, + @JsonProperty("stream") Boolean stream, @JsonProperty("k") Integer k, @JsonProperty("p") Double p, + @JsonProperty("logprobs") Boolean logprobs, @JsonProperty("tool_choice") ToolChoice toolChoice, + @JsonProperty("strict_tools") Boolean strictTools, + @JsonProperty("presence_penalty") Double presencePenalty) { /** * Shortcut constructor for a chat completion request with the given messages and @@ -232,8 +256,8 @@ public record ChatCompletionRequest(@JsonProperty("model") String model, * @param model ID or name of the model to use. */ public ChatCompletionRequest(List messages, String model) { - this(model, messages, null, null, new CitationOptions(CitationMode.FAST), null, SafetyMode.CONTEXTUAL, null, null, 0.3, - null, null, false, 0, 0.75, false, null, false, null); + this(model, messages, null, null, new CitationOptions(CitationMode.FAST), null, SafetyMode.CONTEXTUAL, null, + null, 0.3, null, null, false, 0, 0.75, false, null, false, null); } /** @@ -247,9 +271,9 @@ public ChatCompletionRequest(List messages, String model) * sent */ public ChatCompletionRequest(List messages, String model, Double temperature, - boolean stream) { - this(model, messages, null, null, new CitationOptions(CitationMode.FAST), null, SafetyMode.CONTEXTUAL, null, null, - temperature, null, null, stream, 0, 0.75, false, null, false, null); + boolean stream) { + this(model, messages, null, null, new CitationOptions(CitationMode.FAST), null, SafetyMode.CONTEXTUAL, null, + null, temperature, null, null, stream, 0, 0.75, false, null, false, null); } /** @@ -262,8 +286,8 @@ public ChatCompletionRequest(List messages, String model, * */ public ChatCompletionRequest(List messages, String model, Double temperature) { - this(model, messages, null, null, new CitationOptions(CitationMode.FAST), null, SafetyMode.CONTEXTUAL, null, null, - temperature, null, null, false, 0, 0.75, false, null, false, null); + this(model, messages, null, null, new CitationOptions(CitationMode.FAST), null, SafetyMode.CONTEXTUAL, null, + null, temperature, null, null, false, 0, 0.75, false, null, false, null); } /** @@ -277,9 +301,9 @@ public ChatCompletionRequest(List messages, String model, * @param toolChoice Controls which (if any) function is called by the model. */ public ChatCompletionRequest(List messages, String model, List tools, - ToolChoice toolChoice) { - this(model, messages, tools, null, new CitationOptions(CitationMode.FAST), null, SafetyMode.CONTEXTUAL, null, null, 0.75, - null, null, false, 0, 0.75, false, toolChoice, false, null); + ToolChoice toolChoice) { + this(model, messages, tools, null, new CitationOptions(CitationMode.FAST), null, SafetyMode.CONTEXTUAL, + null, null, 0.75, null, null, false, 0, 0.75, false, toolChoice, false, null); } /** @@ -287,8 +311,8 @@ public ChatCompletionRequest(List messages, String model, * stream. */ public ChatCompletionRequest(List messages, Boolean stream) { - this(null, messages, null, null, new CitationOptions(CitationMode.FAST), null, SafetyMode.CONTEXTUAL, null, null, 0.75, - null, null, stream, 0, 0.75, false, null, false, null); + this(null, messages, null, null, new CitationOptions(CitationMode.FAST), null, SafetyMode.CONTEXTUAL, null, + null, 0.75, null, null, stream, 0, 0.75, false, null, false, null); } /** @@ -299,7 +323,7 @@ public ChatCompletionRequest(List messages, Boolean strea */ @JsonInclude(JsonInclude.Include.NON_NULL) public record ResponseFormat(@JsonProperty("type") String type, - @JsonProperty("json_schema") Map jsonSchema) { + @JsonProperty("json_schema") Map jsonSchema) { } /** @@ -323,17 +347,14 @@ public enum ToolChoice { * Applicable only for {@link Role#ASSISTANT} role and null otherwise. * @param toolPlan A chain-of-thought style reflection and plan that the model * generates when working with Tools. - * @param rawContent The contents of the message. Can be either a {@link MediaContent} or - * a {@link MessageContent}. + * @param rawContent The contents of the message. Can be either a {@link MediaContent} + * or a {@link MessageContent}. * @param citations Tool call that this message is responding to. Only applicable for * the {@link ChatCompletionFinishReason#TOOL_CALL} role and null otherwise. */ - public record ChatCompletionMessage( - @JsonProperty("content") Object rawContent, - @JsonProperty("role") Role role, - //@JsonProperty("name") String name, - @JsonProperty("tool_plan") String toolPlan, - @JsonProperty("tool_calls") List toolCalls, + public record ChatCompletionMessage(@JsonProperty("content") Object rawContent, @JsonProperty("role") Role role, + // @JsonProperty("name") String name, + @JsonProperty("tool_plan") String toolPlan, @JsonProperty("tool_calls") List toolCalls, @JsonProperty("citations") List citations) { public ChatCompletionMessage(Object content, Role role) { @@ -354,7 +375,7 @@ public ChatCompletionMessage(Object content, Role role, List toolCalls */ @JsonInclude(JsonInclude.Include.NON_NULL) public record MediaContent(@JsonProperty("type") String type, @JsonProperty("text") String text, - @JsonProperty("image_url") ImageUrl imageUrl) { + @JsonProperty("image_url") ImageUrl imageUrl) { /** * Shortcut constructor for a text rawContent. @@ -385,7 +406,6 @@ public record ImageUrl(@JsonProperty("url") String url) { } } - /** * Message rawContent that can be either a text or a value. * @@ -393,10 +413,9 @@ public record ImageUrl(@JsonProperty("url") String url) { * @param text The text rawContent of the message. * @param value The value of the thinking, which can be any object. */ - public record MessageContent( - @JsonProperty("type") String type, - @JsonProperty("text") String text, - @JsonProperty("value") Object value) {} + public record MessageContent(@JsonProperty("type") String type, @JsonProperty("text") String text, + @JsonProperty("value") Object value) { + } /** * The role of the author of this message. @@ -438,7 +457,7 @@ public enum Role { */ @JsonInclude(JsonInclude.Include.NON_NULL) public record ToolCall(@JsonProperty("id") String id, @JsonProperty("type") String type, - @JsonProperty("function") ChatCompletionFunction function, @JsonProperty("index") Integer index) { + @JsonProperty("function") ChatCompletionFunction function, @JsonProperty("index") Integer index) { } /** @@ -450,7 +469,7 @@ public record ToolCall(@JsonProperty("id") String id, @JsonProperty("type") Stri */ @JsonInclude(JsonInclude.Include.NON_NULL) public record ChatCompletionFunction(@JsonProperty("name") String name, - @JsonProperty("arguments") String arguments) { + @JsonProperty("arguments") String arguments) { } public record ChatCompletionCitation( @@ -485,18 +504,15 @@ public enum Type { * @param document map from strings to any Optional if type == document */ public record Source(@JsonProperty("type") String type, @JsonProperty("id") String id, - @JsonProperty("tool_output") Map toolOutput, - @JsonProperty("document") Map document) { + @JsonProperty("tool_output") Map toolOutput, + @JsonProperty("document") Map document) { } } - public record Provider( - @JsonProperty("content") List content, - @JsonProperty("role") Role role, - @JsonProperty("tool_plan") String toolPlan, - @JsonProperty("tool_calls") List toolCalls, - @JsonProperty("citations") List citations - ) {} + public record Provider(@JsonProperty("content") List content, @JsonProperty("role") Role role, + @JsonProperty("tool_plan") String toolPlan, @JsonProperty("tool_calls") List toolCalls, + @JsonProperty("citations") List citations) { + } } /** @@ -520,7 +536,8 @@ public enum SafetyMode { * Note: command-r7b-12-2024 and command-a-03-2025 only support "fast" and "off" * modes. The default is "fast". */ - public record CitationOptions(@JsonProperty("mode") CitationMode mode) {} + public record CitationOptions(@JsonProperty("mode") CitationMode mode) { + } /** * Options for controlling citation generation. Defaults to "accurate". Dictates the @@ -706,10 +723,10 @@ public void setJsonSchema(String jsonSchema) { */ @JsonInclude(JsonInclude.Include.NON_NULL) public record ChatCompletion(@JsonProperty("id") String id, - @JsonProperty("finish_reason") ChatCompletionFinishReason finishReason, - @JsonProperty("message") ChatCompletionMessage.Provider message, - @JsonProperty("logprobs") LogProbs logprobs, - @JsonProperty("usage") Usage usage) { } + @JsonProperty("finish_reason") ChatCompletionFinishReason finishReason, + @JsonProperty("message") ChatCompletionMessage.Provider message, + @JsonProperty("logprobs") LogProbs logprobs, @JsonProperty("usage") Usage usage) { + } /** * The reason the model stopped generating tokens. @@ -754,7 +771,7 @@ public enum ChatCompletionFinishReason { */ @JsonInclude(JsonInclude.Include.NON_NULL) public record LogProbs(@JsonProperty("token_ids") List tokenIds, @JsonProperty("text") String text, - @JsonProperty("logprobs") List logprobs) { + @JsonProperty("logprobs") List logprobs) { } @@ -788,54 +805,7 @@ public ResponseEntity chatCompletionEntity(ChatCompletionRequest Assert.notNull(chatRequest, "The request body can not be null."); Assert.isTrue(!chatRequest.stream(), "Request must set the stream property to false."); - return this.restClient.post() - .uri("/v2/chat/") - .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 stream property to true."); - - AtomicBoolean isInsideTool = new AtomicBoolean(false); - - return this.webClient.post() - .uri("/v2/chat/") - .body(Mono.just(chatRequest), ChatCompletionRequest.class) - .retrieve() - .bodyToFlux(String.class) - .takeUntil(SSE_DONE_PREDICATE) - .filter(SSE_DONE_PREDICATE.negate()) - .map(content -> ModelOptionsUtils.jsonToObject(content, ChatCompletionChunk.class)) - .map(chunk -> { - if (this.chunkMerger.isStreamingToolFunctionCall(chunk)) { - isInsideTool.set(true); - } - return chunk; - }) - .windowUntil(chunk -> { - if (isInsideTool.get() && this.chunkMerger.isStreamingToolFunctionCallFinish(chunk)) { - isInsideTool.set(false); - return true; - } - return !isInsideTool.get(); - }) - .concatMapIterable(window -> { - Mono mono1 = window.reduce( - new ChatCompletionChunk(null, null, null, null, null, null), - (previous, current) -> this.chunkMerger.merge(previous, current)); - return List.of(mono1); - }) - .flatMap(mono -> mono); + return this.restClient.post().uri("/v2/chat/").body(chatRequest).retrieve().toEntity(ChatCompletion.class); } /** @@ -854,7 +824,7 @@ public Flux chatCompletionStream(ChatCompletionRequest chat @JsonInclude(JsonInclude.Include.NON_NULL) @JsonIgnoreProperties(ignoreUnknown = true) public record ChatCompletionChunk( - // @formatter:off + // @formatter:off @JsonProperty("id") String id, @JsonProperty("object") String object, @JsonProperty("created") Long created, @@ -874,7 +844,7 @@ public record ChatCompletionChunk( @JsonInclude(JsonInclude.Include.NON_NULL) @JsonIgnoreProperties(ignoreUnknown = true) public record ChunkChoice( - // @formatter:off + // @formatter:off @JsonProperty("index") Integer index, @JsonProperty("delta") ChatCompletionMessage delta, @JsonProperty("finish_reason") ChatCompletionFinishReason finishReason, @@ -884,5 +854,113 @@ public record ChunkChoice( } + /** + * List of well-known Cohere embedding models. + * + * @see Cohere Models Overview + */ + public enum EmbeddingModel { + + // @formatter:off + + /** + * A model that allows for text and images to be classified or turned into embeddings + * dimensional - [256, 512, 1024, 1536 (default)] + */ + EMBED("embed-v4.0"), + /** + * Embed v3 Multilingual model for text embeddings. + * Produces 1024-dimensional embeddings suitable for multilingual semantic search, + * clustering, and other text similarity tasks. + */ + EMBED_MULTILINGUAL_V3("embed-multilingual-v3.0"), + + /** + * Embed v3 English model for text embeddings. + * Produces 1024-dimensional embeddings optimized for English text. + */ + EMBED_ENGLISH_V3("embed-english-v3.0"), + + /** + * Embed v3 Multilingual Light model. + * Smaller and faster variant with 1024 dimensions. + */ + EMBED_MULTILINGUAL_LIGHT_V3("embed-multilingual-light-v3.0"), + + /** + * Embed v3 English Light model. + * Smaller and faster English-only variant with 1024 dimensions. + */ + EMBED_ENGLISH_LIGHT_V3("embed-english-light-v3.0"); + // @formatter:on + + private final String value; + + EmbeddingModel(String value) { + this.value = value; + } + + public String getValue() { + return this.value; + } + + } + + public static Builder builder() { + return new Builder(); + } + + /** + * Builder for creating CohereApi instances. + */ + public static class Builder { + + private String baseUrl = DEFAULT_BASE_URL; + + private String apiKey; + + private RestClient.Builder restClientBuilder = RestClient.builder(); + + private WebClient.Builder webClientBuilder = WebClient.builder(); + + private ResponseErrorHandler responseErrorHandler = RetryUtils.DEFAULT_RESPONSE_ERROR_HANDLER; + + public Builder baseUrl(String baseUrl) { + this.baseUrl = baseUrl; + return this; + } + + public Builder apiKey(String apiKey) { + this.apiKey = apiKey; + return this; + } + + public Builder restClientBuilder(RestClient.Builder restClientBuilder) { + this.restClientBuilder = restClientBuilder; + return this; + } + + public Builder webClientBuilder(WebClient.Builder webClientBuilder) { + this.webClientBuilder = webClientBuilder; + return this; + } + + public Builder responseErrorHandler(ResponseErrorHandler responseErrorHandler) { + this.responseErrorHandler = responseErrorHandler; + return this; + } + + public CohereApi build() { + Assert.hasText(this.apiKey, "Cohere API key must be set"); + Assert.hasText(this.baseUrl, "Cohere base URL must be set"); + Assert.notNull(this.restClientBuilder, "RestClient.Builder must not be null"); + Assert.notNull(this.webClientBuilder, "WebClient.Builder must not be null"); + Assert.notNull(this.responseErrorHandler, "ResponseErrorHandler must not be null"); + + return new CohereApi(this.baseUrl, this.apiKey, this.restClientBuilder, this.webClientBuilder, + this.responseErrorHandler); + } + + } } diff --git a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/chat/CohereChatModel.java b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/chat/CohereChatModel.java index 2216444c500..ac8f3e41955 100644 --- a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/chat/CohereChatModel.java +++ b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/chat/CohereChatModel.java @@ -1,3 +1,19 @@ +/* + * Copyright 2023-2025 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.cohere.chat; import io.micrometer.observation.ObservationRegistry; @@ -31,7 +47,11 @@ import org.springframework.ai.chat.prompt.Prompt; import org.springframework.ai.content.Media; import org.springframework.ai.model.ModelOptionsUtils; -import org.springframework.ai.model.tool.*; +import org.springframework.ai.model.tool.DefaultToolExecutionEligibilityPredicate; +import org.springframework.ai.model.tool.ToolCallingManager; +import org.springframework.ai.model.tool.ToolExecutionEligibilityPredicate; +import org.springframework.ai.model.tool.ToolExecutionResult; +import org.springframework.ai.model.tool.ToolCallingChatOptions; import org.springframework.ai.retry.RetryUtils; import org.springframework.ai.support.UsageCalculator; import org.springframework.ai.tool.definition.ToolDefinition; @@ -90,16 +110,15 @@ public class CohereChatModel implements ChatModel { */ private ChatModelObservationConvention observationConvention = DEFAULT_OBSERVATION_CONVENTION; - public CohereChatModel(CohereApi cohereApi, CohereChatOptions defaultOptions, - ToolCallingManager toolCallingManager, RetryTemplate retryTemplate, - ObservationRegistry observationRegistry) { + public CohereChatModel(CohereApi cohereApi, CohereChatOptions defaultOptions, ToolCallingManager toolCallingManager, + RetryTemplate retryTemplate, ObservationRegistry observationRegistry) { this(cohereApi, defaultOptions, toolCallingManager, retryTemplate, observationRegistry, new DefaultToolExecutionEligibilityPredicate()); } - public CohereChatModel(CohereApi cohereApi, CohereChatOptions defaultOptions, - ToolCallingManager toolCallingManager, RetryTemplate retryTemplate, ObservationRegistry observationRegistry, - ToolExecutionEligibilityPredicate toolExecutionEligibilityPredicate) { + public CohereChatModel(CohereApi cohereApi, CohereChatOptions defaultOptions, ToolCallingManager toolCallingManager, + RetryTemplate retryTemplate, ObservationRegistry observationRegistry, + ToolExecutionEligibilityPredicate toolExecutionEligibilityPredicate) { Assert.notNull(cohereApi, "cohereApi cannot be null"); Assert.notNull(defaultOptions, "defaultOptions cannot be null"); Assert.notNull(toolCallingManager, "toolCallingManager cannot be null"); @@ -117,18 +136,12 @@ public CohereChatModel(CohereApi cohereApi, CohereChatOptions defaultOptions, public static ChatResponseMetadata from(CohereApi.ChatCompletion result) { Assert.notNull(result, "Cohere ChatCompletion must not be null"); DefaultUsage usage = getDefaultUsage(result.usage()); - return ChatResponseMetadata.builder() - .id(result.id()) - .usage(usage) - .build(); + return ChatResponseMetadata.builder().id(result.id()).usage(usage).build(); } public static ChatResponseMetadata from(CohereApi.ChatCompletion result, Usage usage) { Assert.notNull(result, "Cohere ChatCompletion must not be null"); - return ChatResponseMetadata.builder() - .id(result.id()) - .usage(usage) - .build(); + return ChatResponseMetadata.builder().id(result.id()).usage(usage).build(); } private static DefaultUsage getDefaultUsage(CohereApi.Usage usage) { @@ -137,8 +150,20 @@ private static DefaultUsage getDefaultUsage(CohereApi.Usage usage) { @Override public ChatResponse call(Prompt prompt) { - Prompt requestPrompt = buildRequestPrompt(prompt); - return this.internalCall(requestPrompt, null); + Prompt requestPrompt = this.buildRequestPrompt(prompt); + ChatModelObservationContext observationContext = ChatModelObservationContext.builder() + .prompt(requestPrompt) + .provider(CohereApi.PROVIDER_NAME) + .build(); + + return ChatModelObservationDocumentation.CHAT_MODEL_OPERATION + .observation(this.observationConvention, DEFAULT_OBSERVATION_CONVENTION, () -> observationContext, + this.observationRegistry) + .observe(() -> { + ChatResponse chatResponse = doChatRequest(requestPrompt); + observationContext.setResponse(chatResponse); + return chatResponse; + }); } @Override @@ -190,56 +215,17 @@ Prompt buildRequestPrompt(Prompt prompt) { return new Prompt(prompt.getInstructions(), requestOptions); } - public ChatResponse internalCall(Prompt prompt, ChatResponse previousChatResponse) { - - ChatCompletionRequest request = createRequest(prompt, false); - - ChatModelObservationContext observationContext = ChatModelObservationContext.builder() - .prompt(prompt) - .provider(CohereApi.PROVIDER_NAME) - .build(); - - ChatResponse response = ChatModelObservationDocumentation.CHAT_MODEL_OPERATION - .observation(this.observationConvention, DEFAULT_OBSERVATION_CONVENTION, () -> observationContext, - this.observationRegistry) - .observe(() -> { - - ResponseEntity completionEntity = this.retryTemplate - .execute(ctx -> this.cohereApi.chatCompletionEntity(request)); - - ChatCompletion chatCompletion = completionEntity.getBody(); - - if (chatCompletion == null) { - logger.warn("No chat completion returned for prompt: {}", prompt); - return new ChatResponse(List.of()); - } - - List generations = chatCompletion.message().content().stream().map(content -> { - Map metadata = Map.of( - "id", chatCompletion.id() != null ? chatCompletion.id() : "", - "role", chatCompletion.message().role() != null ? chatCompletion.message().role().name() : "", - "finishReason", chatCompletion.finishReason() != null ? chatCompletion.finishReason().name() : ""); - return buildGeneration(content, chatCompletion, metadata); - }).toList(); - - DefaultUsage usage = getDefaultUsage(completionEntity.getBody().usage()); - Usage cumulativeUsage = UsageCalculator.getCumulativeUsage(usage, previousChatResponse); - ChatResponse chatResponse = new ChatResponse(generations, - from(completionEntity.getBody(), cumulativeUsage)); - - observationContext.setResponse(chatResponse); - - return chatResponse; - }); + private ChatResponse doChatRequest(Prompt prompt) { + ChatResponse response = this.internalCall(prompt, null); if (this.toolExecutionEligibilityPredicate.isToolExecutionRequired(prompt.getOptions(), response)) { var toolExecutionResult = this.toolCallingManager.executeToolCalls(prompt, response); if (toolExecutionResult.returnDirect()) { // Return tool execution result directly to the client. return ChatResponse.builder() - .from(response) - .generations(ToolExecutionResult.buildGenerations(toolExecutionResult)) - .build(); + .from(response) + .generations(ToolExecutionResult.buildGenerations(toolExecutionResult)) + .build(); } else { // Send the tool execution result back to the model. @@ -251,16 +237,52 @@ public ChatResponse internalCall(Prompt prompt, ChatResponse previousChatRespons return response; } - private Generation buildGeneration(ChatCompletionMessage.MessageContent content, ChatCompletion completion, Map metadata) { + private ChatResponse internalCall(Prompt prompt, ChatResponse previousChatResponse) { + ChatCompletionRequest request = createRequest(prompt, false); + + return this.retryTemplate.execute(ctx -> { + + ResponseEntity completionEntity = this.cohereApi.chatCompletionEntity(request); + + var chatCompletion = completionEntity.getBody(); + if (chatCompletion == null) { + logger.warn("No chat completion returned for prompt: {}", prompt); + return new ChatResponse(List.of()); + } + + List generations = chatCompletion.message().content().stream().map(content -> { + Map metadata = Map.of("id", chatCompletion.id() != null ? chatCompletion.id() : "", + "role", chatCompletion.message().role() != null ? chatCompletion.message().role().name() : "", + "finishReason", + chatCompletion.finishReason() != null ? chatCompletion.finishReason().name() : ""); + return buildGeneration(content, chatCompletion, metadata); + }).toList(); + + DefaultUsage usage = getDefaultUsage(completionEntity.getBody().usage()); + Usage cumulativeUsage = UsageCalculator.getCumulativeUsage(usage, previousChatResponse); + ChatResponse chatResponse = new ChatResponse(generations, + from(completionEntity.getBody(), cumulativeUsage)); + + return chatResponse; + }); + } + + private Generation buildGeneration(ChatCompletionMessage.MessageContent content, ChatCompletion completion, + Map metadata) { List toolCalls = completion.message().toolCalls() == null ? List.of() : completion.message() - .toolCalls() - .stream() - .map(toolCall -> new AssistantMessage.ToolCall(toolCall.id(), "function", - toolCall.function().name(), toolCall.function().arguments())) - .toList(); + .toolCalls() + .stream() + .map(toolCall -> new AssistantMessage.ToolCall(toolCall.id(), "function", + toolCall.function().name(), toolCall.function().arguments())) + .toList(); + + var assistantMessage = AssistantMessage.builder() + .content(content.text()) + .toolCalls(toolCalls) + .properties(metadata) + .build(); - var assistantMessage = new AssistantMessage(content.text(), metadata, toolCalls); String finishReason = (completion.finishReason() != null ? completion.finishReason().name() : ""); var generationMetadata = ChatGenerationMetadata.builder().finishReason(finishReason).build(); return new Generation(assistantMessage, generationMetadata); @@ -283,12 +305,10 @@ CohereApi.ChatCompletionRequest createRequest(Prompt prompt, boolean stream) { content = contentList; } - return List - .of(new ChatCompletionMessage(content, Role.USER)); + return List.of(new ChatCompletionMessage(content, Role.USER)); } else if (message instanceof SystemMessage systemMessage) { - return List.of(new ChatCompletionMessage(systemMessage.getText(), - Role.SYSTEM)); + return List.of(new ChatCompletionMessage(systemMessage.getText(), Role.SYSTEM)); } else if (message instanceof AssistantMessage assistantMessage) { List toolCalls = null; @@ -299,18 +319,16 @@ else if (message instanceof AssistantMessage assistantMessage) { }).toList(); } - return List.of(new ChatCompletionMessage(assistantMessage.getText(), - Role.ASSISTANT, toolCalls)); + return List.of(new ChatCompletionMessage(assistantMessage.getText(), Role.ASSISTANT, toolCalls)); } else if (message instanceof ToolResponseMessage toolResponseMessage) { toolResponseMessage.getResponses() - .forEach(response -> Assert.isTrue(response.id() != null, "ToolResponseMessage must have an id")); + .forEach(response -> Assert.isTrue(response.id() != null, "ToolResponseMessage must have an id")); return toolResponseMessage.getResponses() - .stream() - .map(toolResponse -> new ChatCompletionMessage(toolResponse.responseData(), - Role.TOOL)) - .toList(); + .stream() + .map(toolResponse -> new ChatCompletionMessage(toolResponse.responseData(), Role.TOOL)) + .toList(); } else { throw new IllegalStateException("Unexpected message type: " + message); @@ -385,10 +403,10 @@ public static final class Builder { private CohereApi cohereApi; private CohereChatOptions defaultOptions = CohereChatOptions.builder() - .temperature(0.3) - .topP(1.0) - .model(CohereApi.ChatModel.COMMAND_R7B.getValue()) - .build(); + .temperature(0.3) + .topP(1.0) + .model(CohereApi.ChatModel.COMMAND_A_R7B.getValue()) + .build(); private ToolCallingManager toolCallingManager; diff --git a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/chat/CohereChatOptions.java b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/chat/CohereChatOptions.java index 1f133b1b36b..1bd094bf3ec 100644 --- a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/chat/CohereChatOptions.java +++ b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/chat/CohereChatOptions.java @@ -1,3 +1,19 @@ +/* + * Copyright 2023-2025 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.cohere.chat; import com.fasterxml.jackson.annotation.JsonIgnore; @@ -123,8 +139,7 @@ public class CohereChatOptions implements ToolCallingChatOptions { * function via {"type: "function", "function": {"name": "my_function"}} forces the * model to call that function. none is the default when no functions are present. * auto is the default if functions are present. Use the - * {@link CohereApi.ToolChoiceBuilder} to create - * a tool choice object. + * {@link CohereApi.ToolChoiceBuilder} to create a tool choice object. */ private @JsonProperty("tool_choice") ToolChoice toolChoice; @@ -365,24 +380,25 @@ public static Builder builder() { public static CohereChatOptions fromOptions(CohereChatOptions fromOptions) { return CohereChatOptions.builder() - .model(fromOptions.getModel()) - .temperature(fromOptions.getTemperature()) - .maxTokens(fromOptions.getMaxTokens()) - .topP(fromOptions.getTopP()) - .frequencyPenalty(fromOptions.getFrequencyPenalty()) - .presencePenalty(fromOptions.getPresencePenalty()) - .topK(fromOptions.getTopK()) - .tools(fromOptions.getTools()) - .responseFormat(fromOptions.getResponseFormat()) - .safetyMode(fromOptions.getSafetyMode()) - .stop(fromOptions.getStopSequences()) - .seed(fromOptions.getSeed()) - .logprobs(fromOptions.getLogprobs()) - .toolChoice(fromOptions.getToolChoice()) - .strictTools(fromOptions.getStrictTools()) - .toolCallbacks(fromOptions.getToolCallbacks()) - .toolNames(fromOptions.getToolNames()) - .internalToolExecutionEnabled(fromOptions.getInternalToolExecutionEnabled()).build(); + .model(fromOptions.getModel()) + .temperature(fromOptions.getTemperature()) + .maxTokens(fromOptions.getMaxTokens()) + .topP(fromOptions.getTopP()) + .frequencyPenalty(fromOptions.getFrequencyPenalty()) + .presencePenalty(fromOptions.getPresencePenalty()) + .topK(fromOptions.getTopK()) + .tools(fromOptions.getTools()) + .responseFormat(fromOptions.getResponseFormat()) + .safetyMode(fromOptions.getSafetyMode()) + .stop(fromOptions.getStopSequences()) + .seed(fromOptions.getSeed()) + .logprobs(fromOptions.getLogprobs()) + .toolChoice(fromOptions.getToolChoice()) + .strictTools(fromOptions.getStrictTools()) + .toolCallbacks(fromOptions.getToolCallbacks()) + .toolNames(fromOptions.getToolNames()) + .internalToolExecutionEnabled(fromOptions.getInternalToolExecutionEnabled()) + .build(); } public static class Builder { diff --git a/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/CohereTestConfiguration.java b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/CohereTestConfiguration.java new file mode 100644 index 00000000000..2042f1a0c7f --- /dev/null +++ b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/CohereTestConfiguration.java @@ -0,0 +1,54 @@ +/* + * Copyright 2023-2025 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.cohere; + +import org.springframework.ai.cohere.api.CohereApi; +import org.springframework.ai.cohere.chat.CohereChatModel; +import org.springframework.ai.cohere.chat.CohereChatOptions; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.util.StringUtils; + +/** + * @author Ricken Bazolo + */ +@SpringBootConfiguration +public class CohereTestConfiguration { + + private static String retrieveApiKey() { + var apiKey = System.getenv("COHERE_API_KEY"); + if (!StringUtils.hasText(apiKey)) { + throw new IllegalArgumentException( + "Missing COHERE_API_KEY environment variable. Please set it to your Cohere API key."); + } + return apiKey; + } + + @Bean + public CohereApi cohereApi() { + return CohereApi.builder().apiKey(retrieveApiKey()).build(); + } + + @Bean + public CohereChatModel mistralAiChatModel(CohereApi api) { + return CohereChatModel.builder() + .cohereApi(api) + .defaultOptions(CohereChatOptions.builder().model(CohereApi.ChatModel.COMMAND_A.getValue()).build()) + .build(); + } + +} diff --git a/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/aot/CohereRuntimeHintsTests.java b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/aot/CohereRuntimeHintsTests.java new file mode 100644 index 00000000000..cf4c1f05298 --- /dev/null +++ b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/aot/CohereRuntimeHintsTests.java @@ -0,0 +1,242 @@ +/* + * 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.cohere.aot; + +import org.junit.jupiter.api.Test; +import org.springframework.ai.cohere.api.CohereApi; +import org.springframework.ai.cohere.chat.CohereChatOptions; +import org.springframework.aot.hint.RuntimeHints; +import org.springframework.aot.hint.TypeReference; + +import java.util.HashSet; +import java.util.Set; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatCode; +import static org.springframework.ai.aot.AiRuntimeHints.findJsonAnnotatedClassesInPackage; + +class CohereRuntimeHintsTests { + + @Test + void registerHints() { + RuntimeHints runtimeHints = new RuntimeHints(); + CohereRuntimeHints cohereRuntimeHints = new CohereRuntimeHints(); + cohereRuntimeHints.registerHints(runtimeHints, null); + + Set jsonAnnotatedClasses = findJsonAnnotatedClassesInPackage("org.springframework.ai.cohere"); + + Set registeredTypes = new HashSet<>(); + runtimeHints.reflection().typeHints().forEach(typeHint -> registeredTypes.add(typeHint.getType())); + + for (TypeReference jsonAnnotatedClass : jsonAnnotatedClasses) { + assertThat(registeredTypes.contains(jsonAnnotatedClass)).isTrue(); + } + + // Check a few more specific ones + assertThat(registeredTypes.contains(TypeReference.of(CohereApi.ChatCompletion.class))).isTrue(); + assertThat(registeredTypes.contains(TypeReference.of(CohereApi.ChatCompletionChunk.class))).isTrue(); + assertThat(registeredTypes.contains(TypeReference.of(CohereApi.LogProbs.class))).isTrue(); + assertThat(registeredTypes.contains(TypeReference.of(CohereApi.ChatCompletionFinishReason.class))).isTrue(); + assertThat(registeredTypes.contains(TypeReference.of(CohereChatOptions.class))).isTrue(); + } + + @Test + void registerHintsWithNullClassLoader() { + RuntimeHints runtimeHints = new RuntimeHints(); + CohereRuntimeHints cohereRuntimeHints = new CohereRuntimeHints(); + + // Should not throw exception with null classLoader + cohereRuntimeHints.registerHints(runtimeHints, null); + + // Verify hints were registered + assertThat(runtimeHints.reflection().typeHints().count()).isGreaterThan(0); + } + + @Test + void registerHintsWithValidClassLoader() { + RuntimeHints runtimeHints = new RuntimeHints(); + CohereRuntimeHints cohereRuntimeHints = new CohereRuntimeHints(); + ClassLoader classLoader = Thread.currentThread().getContextClassLoader(); + + cohereRuntimeHints.registerHints(runtimeHints, classLoader); + + // Verify hints were registered + assertThat(runtimeHints.reflection().typeHints().count()).isGreaterThan(0); + } + + @Test + void registerHintsIsIdempotent() { + RuntimeHints runtimeHints = new RuntimeHints(); + CohereRuntimeHints cohereRuntimeHints = new CohereRuntimeHints(); + + // Register hints twice + cohereRuntimeHints.registerHints(runtimeHints, null); + long firstCount = runtimeHints.reflection().typeHints().count(); + + cohereRuntimeHints.registerHints(runtimeHints, null); + long secondCount = runtimeHints.reflection().typeHints().count(); + + // Should have same number of hints + assertThat(firstCount).isEqualTo(secondCount); + } + + @Test + void verifyExpectedTypesAreRegistered() { + RuntimeHints runtimeHints = new RuntimeHints(); + CohereRuntimeHints cohereRuntimeHints = new CohereRuntimeHints(); + cohereRuntimeHints.registerHints(runtimeHints, null); + + Set registeredTypes = new HashSet<>(); + runtimeHints.reflection().typeHints().forEach(typeHint -> registeredTypes.add(typeHint.getType())); + + // Verify some expected types are registered (adjust class names as needed) + assertThat(registeredTypes.stream().anyMatch(tr -> tr.getName().contains("Cohere"))).isTrue(); + assertThat(registeredTypes.stream().anyMatch(tr -> tr.getName().contains("ChatCompletion"))).isTrue(); + } + + @Test + void verifyPackageScanningWorks() { + Set jsonAnnotatedClasses = findJsonAnnotatedClassesInPackage("org.springframework.ai.cohere"); + + // Verify package scanning found classes + assertThat(jsonAnnotatedClasses.size()).isGreaterThan(0); + } + + @Test + void verifyAllCriticalApiClassesAreRegistered() { + RuntimeHints runtimeHints = new RuntimeHints(); + CohereRuntimeHints cohereRuntimeHints = new CohereRuntimeHints(); + cohereRuntimeHints.registerHints(runtimeHints, null); + + Set registeredTypes = new HashSet<>(); + runtimeHints.reflection().typeHints().forEach(typeHint -> registeredTypes.add(typeHint.getType())); + + // Ensure critical API classes are registered for GraalVM native image reflection + String[] criticalClasses = { "CohereApi$ChatCompletionRequest", "CohereApi$ChatCompletionMessage", + "CohereApi$EmbeddingRequest", "CohereApi$EmbeddingList", "CohereApi$Usage" }; + + for (String className : criticalClasses) { + assertThat(registeredTypes.stream() + .anyMatch(tr -> tr.getName().contains(className.replace("$", ".")) + || tr.getName().contains(className.replace("$", "$")))) + .as("Critical class %s should be registered", className) + .isTrue(); + } + } + + @Test + void verifyEnumTypesAreRegistered() { + RuntimeHints runtimeHints = new RuntimeHints(); + CohereRuntimeHints cohereRuntimeHints = new CohereRuntimeHints(); + cohereRuntimeHints.registerHints(runtimeHints, null); + + Set registeredTypes = new HashSet<>(); + runtimeHints.reflection().typeHints().forEach(typeHint -> registeredTypes.add(typeHint.getType())); + + // Enums are critical for JSON deserialization in native images + assertThat(registeredTypes.contains(TypeReference.of(CohereApi.ChatModel.class))) + .as("ChatModel enum should be registered") + .isTrue(); + + assertThat(registeredTypes.contains(TypeReference.of(CohereApi.EmbeddingModel.class))) + .as("EmbeddingModel enum should be registered") + .isTrue(); + } + + @Test + void verifyReflectionHintsIncludeConstructors() { + RuntimeHints runtimeHints = new RuntimeHints(); + CohereRuntimeHints cohereRuntimeHints = new CohereRuntimeHints(); + cohereRuntimeHints.registerHints(runtimeHints, null); + + // Verify that reflection hints include constructor access + boolean hasConstructorHints = runtimeHints.reflection() + .typeHints() + .anyMatch(typeHint -> typeHint.constructors().findAny().isPresent() || typeHint.getMemberCategories() + .contains(org.springframework.aot.hint.MemberCategory.INVOKE_DECLARED_CONSTRUCTORS)); + + assertThat(hasConstructorHints).as("Should register constructor hints for JSON deserialization").isTrue(); + } + + @Test + void verifyNoExceptionThrownWithEmptyRuntimeHints() { + RuntimeHints emptyRuntimeHints = new RuntimeHints(); + CohereRuntimeHints cohereRuntimeHints = new CohereRuntimeHints(); + + // Should not throw any exception even with empty runtime hints + assertThatCode(() -> cohereRuntimeHints.registerHints(emptyRuntimeHints, null)).doesNotThrowAnyException(); + + assertThat(emptyRuntimeHints.reflection().typeHints().count()).isGreaterThan(0); + } + + @Test + void verifyProxyHintsAreNotRegistered() { + RuntimeHints runtimeHints = new RuntimeHints(); + CohereRuntimeHints cohereRuntimeHints = new CohereRuntimeHints(); + cohereRuntimeHints.registerHints(runtimeHints, null); + + // MistralAi should only register reflection hints, not proxy hints + assertThat(runtimeHints.proxies().jdkProxyHints().count()).isEqualTo(0); + } + + @Test + void verifySerializationHintsAreNotRegistered() { + RuntimeHints runtimeHints = new RuntimeHints(); + CohereRuntimeHints cohereRuntimeHints = new CohereRuntimeHints(); + cohereRuntimeHints.registerHints(runtimeHints, null); + + // MistralAi should only register reflection hints, not serialization hints + assertThat(runtimeHints.serialization().javaSerializationHints().count()).isEqualTo(0); + } + + @Test + void verifyResponseTypesAreRegistered() { + RuntimeHints runtimeHints = new RuntimeHints(); + CohereRuntimeHints cohereRuntimeHints = new CohereRuntimeHints(); + cohereRuntimeHints.registerHints(runtimeHints, null); + + Set registeredTypes = new HashSet<>(); + runtimeHints.reflection().typeHints().forEach(typeHint -> registeredTypes.add(typeHint.getType())); + + // Verify response wrapper types are registered + assertThat(registeredTypes.stream().anyMatch(tr -> tr.getName().contains("EmbeddingList"))) + .as("EmbeddingList response type should be registered") + .isTrue(); + + assertThat(registeredTypes.stream().anyMatch(tr -> tr.getName().contains("ChatCompletion"))) + .as("ChatCompletion response type should be registered") + .isTrue(); + } + + @Test + void verifyMultipleInstancesRegisterSameHints() { + RuntimeHints runtimeHints1 = new RuntimeHints(); + RuntimeHints runtimeHints2 = new RuntimeHints(); + + CohereRuntimeHints hints1 = new CohereRuntimeHints(); + CohereRuntimeHints hints2 = new CohereRuntimeHints(); + + hints1.registerHints(runtimeHints1, null); + hints2.registerHints(runtimeHints2, null); + + long count1 = runtimeHints1.reflection().typeHints().count(); + long count2 = runtimeHints2.reflection().typeHints().count(); + + assertThat(count1).isEqualTo(count2); + } + +} diff --git a/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/api/CohereApiIT.java b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/api/CohereApiIT.java new file mode 100644 index 00000000000..3902eab1d4a --- /dev/null +++ b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/api/CohereApiIT.java @@ -0,0 +1,53 @@ +package org.springframework.ai.cohere.api; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.ai.cohere.CohereTestConfiguration; +import org.springframework.ai.cohere.testutils.AbstractIT; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.ResponseEntity; + +import org.springframework.ai.cohere.api.CohereApi.ChatCompletion; +import org.springframework.ai.cohere.api.CohereApi.ChatCompletionMessage; +import org.springframework.ai.cohere.api.CohereApi.ChatCompletionMessage.Role; +import org.springframework.ai.cohere.api.CohereApi.ChatCompletionRequest; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Ricken Bazolo + */ +@SpringBootTest(classes = CohereTestConfiguration.class) +@EnabledIfEnvironmentVariable(named = "COHERE_API_KEY", matches = ".+") +class CohereApiIT extends AbstractIT { + + @Test + void chatCompletionEntity() { + ChatCompletionMessage chatCompletionMessage = new ChatCompletionMessage("Hello world", Role.USER); + ResponseEntity response = this.cohereApi.chatCompletionEntity(new ChatCompletionRequest( + List.of(chatCompletionMessage), CohereApi.ChatModel.COMMAND_A_R7B.getValue(), 0.8, 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 = this.cohereApi.chatCompletionEntity(new ChatCompletionRequest( + List.of(systemMessage, userMessage), CohereApi.ChatModel.COMMAND_A_R7B.getValue(), 0.8, false)); + + assertThat(response).isNotNull(); + assertThat(response.getBody()).isNotNull(); + } + +} diff --git a/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/testutils/AbstractIT.java b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/testutils/AbstractIT.java new file mode 100644 index 00000000000..0dc98ae1805 --- /dev/null +++ b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/testutils/AbstractIT.java @@ -0,0 +1,100 @@ +/* + * 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.cohere.testutils; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.SystemMessage; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.model.StreamingChatModel; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.chat.prompt.PromptTemplate; +import org.springframework.ai.cohere.api.CohereApi; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.Resource; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; + +public abstract class AbstractIT { + + private static final Logger logger = LoggerFactory.getLogger(AbstractIT.class); + + @Autowired + protected ChatModel chatModel; + + @Autowired + protected CohereApi cohereApi; + + @Autowired + protected StreamingChatModel streamingChatModel; + + @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 qaEvaluatorFactBasedAnswerResource; + + @Value("classpath:/prompts/eval/user-evaluator-message.st") + protected Resource userEvaluatorResource; + + @Value("classpath:/prompts/system-message.st") + protected Resource systemResource; + + protected void evaluateQuestionAndAnswer(String question, ChatResponse response, boolean factBased) { + assertThat(response).isNotNull(); + String answer = response.getResult().getOutput().getText(); + logger.info("Question: {}", question); + logger.info("Answer:{}", answer); + PromptTemplate userPromptTemplate = PromptTemplate.builder() + .resource(this.userEvaluatorResource) + .variables(Map.of("question", question, "answer", answer)) + .build(); + SystemMessage systemMessage; + if (factBased) { + systemMessage = new SystemMessage(this.qaEvaluatorFactBasedAnswerResource); + } + else { + systemMessage = new SystemMessage(this.qaEvaluatorAccurateAnswerResource); + } + Message userMessage = userPromptTemplate.createMessage(); + Prompt prompt = new Prompt(List.of(userMessage, systemMessage)); + String yesOrNo = this.chatModel.call(prompt).getResult().getOutput().getText(); + logger.info("Is Answer related to question: {}", yesOrNo); + assert yesOrNo != null; + if (yesOrNo.equalsIgnoreCase("no")) { + SystemMessage notRelatedSystemMessage = new SystemMessage(this.qaEvaluatorNotRelatedResource); + prompt = new Prompt(List.of(userMessage, notRelatedSystemMessage)); + String reasonForFailure = this.chatModel.call(prompt).getResult().getOutput().getText(); + fail(reasonForFailure); + } + else { + logger.info("Answer is related to question."); + assertThat(yesOrNo).isEqualTo("YES"); + } + } + +} diff --git a/spring-ai-commons/src/main/java/org/springframework/ai/observation/conventions/AiProvider.java b/spring-ai-commons/src/main/java/org/springframework/ai/observation/conventions/AiProvider.java index 7f22cf31f5e..49f460a4bc3 100644 --- a/spring-ai-commons/src/main/java/org/springframework/ai/observation/conventions/AiProvider.java +++ b/spring-ai-commons/src/main/java/org/springframework/ai/observation/conventions/AiProvider.java @@ -45,6 +45,11 @@ public enum AiProvider { */ BEDROCK_CONVERSE("bedrock_converse"), + /** + * AI system provided by Cohere. + */ + COHERE("cohere"), + /** * AI system provided by DeepSeek. */ @@ -98,11 +103,7 @@ public enum AiProvider { /** * AI system provided by Zhipuai. */ - ZHIPUAI("zhipuai"), - /** - * AI system provided by Cohere. - */ - COHERE("cohere"); + ZHIPUAI("zhipuai"); private final String value; diff --git a/spring-ai-spring-boot-starters/spring-ai-starter-model-cohere/pom.xml b/spring-ai-spring-boot-starters/spring-ai-starter-model-cohere/pom.xml index 5c11b7084be..4b575c7edf9 100644 --- a/spring-ai-spring-boot-starters/spring-ai-starter-model-cohere/pom.xml +++ b/spring-ai-spring-boot-starters/spring-ai-starter-model-cohere/pom.xml @@ -4,7 +4,7 @@ org.springframework.ai spring-ai-parent - 1.1.0-SNAPSHOT + 2.0.0-SNAPSHOT ../../pom.xml spring-ai-starter-model-cohere From 55b9d3034a0bb65bb20bbba46d6fb30871c2579a Mon Sep 17 00:00:00 2001 From: Ricken Bazolo Date: Sun, 16 Nov 2025 16:23:18 +0100 Subject: [PATCH 05/18] added cohere support :: fix CohereRuntimeHintsTests Signed-off-by: ricken07 --- .../ai/cohere/aot/CohereRuntimeHintsTests.java | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/aot/CohereRuntimeHintsTests.java b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/aot/CohereRuntimeHintsTests.java index cf4c1f05298..df2d128e201 100644 --- a/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/aot/CohereRuntimeHintsTests.java +++ b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/aot/CohereRuntimeHintsTests.java @@ -19,6 +19,7 @@ import org.junit.jupiter.api.Test; import org.springframework.ai.cohere.api.CohereApi; import org.springframework.ai.cohere.chat.CohereChatOptions; +import org.springframework.ai.cohere.embedding.CohereEmbeddingOptions; import org.springframework.aot.hint.RuntimeHints; import org.springframework.aot.hint.TypeReference; @@ -52,6 +53,7 @@ void registerHints() { assertThat(registeredTypes.contains(TypeReference.of(CohereApi.LogProbs.class))).isTrue(); assertThat(registeredTypes.contains(TypeReference.of(CohereApi.ChatCompletionFinishReason.class))).isTrue(); assertThat(registeredTypes.contains(TypeReference.of(CohereChatOptions.class))).isTrue(); + assertThat(registeredTypes.contains(TypeReference.of(CohereEmbeddingOptions.class))).isTrue(); } @Test @@ -127,7 +129,7 @@ void verifyAllCriticalApiClassesAreRegistered() { // Ensure critical API classes are registered for GraalVM native image reflection String[] criticalClasses = { "CohereApi$ChatCompletionRequest", "CohereApi$ChatCompletionMessage", - "CohereApi$EmbeddingRequest", "CohereApi$EmbeddingList", "CohereApi$Usage" }; + "CohereApi$EmbeddingRequest", "CohereApi$EmbeddingModel", "CohereApi$Usage" }; for (String className : criticalClasses) { assertThat(registeredTypes.stream() @@ -213,8 +215,8 @@ void verifyResponseTypesAreRegistered() { runtimeHints.reflection().typeHints().forEach(typeHint -> registeredTypes.add(typeHint.getType())); // Verify response wrapper types are registered - assertThat(registeredTypes.stream().anyMatch(tr -> tr.getName().contains("EmbeddingList"))) - .as("EmbeddingList response type should be registered") + assertThat(registeredTypes.stream().anyMatch(tr -> tr.getName().contains("EmbeddingResponse"))) + .as("EmbeddingResponse type should be registered") .isTrue(); assertThat(registeredTypes.stream().anyMatch(tr -> tr.getName().contains("ChatCompletion"))) From cb27a54c3fec3afc00611dc518eb13d05b08454c Mon Sep 17 00:00:00 2001 From: Ricken Bazolo Date: Sun, 16 Nov 2025 18:55:55 +0100 Subject: [PATCH 06/18] added cohere support :: using builder Signed-off-by: ricken07 --- .../cohere/autoconfigure/CohereChatAutoConfiguration.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/java/org/springframework/ai/cohere/autoconfigure/CohereChatAutoConfiguration.java b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/java/org/springframework/ai/cohere/autoconfigure/CohereChatAutoConfiguration.java index 1b9a7f3df0c..0bda25e6ffe 100644 --- a/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/java/org/springframework/ai/cohere/autoconfigure/CohereChatAutoConfiguration.java +++ b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/java/org/springframework/ai/cohere/autoconfigure/CohereChatAutoConfiguration.java @@ -80,7 +80,13 @@ private CohereApi cohereApi(String apiKey, String commonApiKey, String baseUrl, Assert.hasText(resolvedApiKey, "Cohere API key must be set"); Assert.hasText(resoledBaseUrl, "Cohere base URL must be set"); - return new CohereApi(resoledBaseUrl, resolvedApiKey, restClientBuilder, webClientBuilder, responseErrorHandler); + return CohereApi.builder() + .baseUrl(resoledBaseUrl) + .apiKey(resolvedApiKey) + .restClientBuilder(restClientBuilder) + .webClientBuilder(webClientBuilder) + .responseErrorHandler(responseErrorHandler) + .build(); } } From 59d6e6d1eac75990cf0ccbd86e8761bc8c02e52c Mon Sep 17 00:00:00 2001 From: Ricken Bazolo Date: Sun, 16 Nov 2025 19:52:47 +0100 Subject: [PATCH 07/18] added cohere support :: embedding model Signed-off-by: ricken07 --- .../CohereEmbeddingAutoConfiguration.java | 94 ++++++ .../CohereEmbeddingProperties.java | 67 +++++ ...ot.autoconfigure.AutoConfiguration.imports | 1 + .../ai/cohere/api/CohereApi.java | 268 +++++++++++++++++- .../embedding/CohereEmbeddingModel.java | 244 ++++++++++++++++ .../embedding/CohereEmbeddingOptions.java | 185 ++++++++++++ 6 files changed, 855 insertions(+), 4 deletions(-) create mode 100644 auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/java/org/springframework/ai/cohere/autoconfigure/CohereEmbeddingAutoConfiguration.java create mode 100644 auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/java/org/springframework/ai/cohere/autoconfigure/CohereEmbeddingProperties.java create mode 100644 models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/embedding/CohereEmbeddingModel.java create mode 100644 models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/embedding/CohereEmbeddingOptions.java diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/java/org/springframework/ai/cohere/autoconfigure/CohereEmbeddingAutoConfiguration.java b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/java/org/springframework/ai/cohere/autoconfigure/CohereEmbeddingAutoConfiguration.java new file mode 100644 index 00000000000..2187dee58dd --- /dev/null +++ b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/java/org/springframework/ai/cohere/autoconfigure/CohereEmbeddingAutoConfiguration.java @@ -0,0 +1,94 @@ +/* + * Copyright 2023-2025 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.cohere.autoconfigure; + +import io.micrometer.observation.ObservationRegistry; +import org.springframework.ai.cohere.api.CohereApi; +import org.springframework.ai.cohere.embedding.CohereEmbeddingModel; +import org.springframework.ai.embedding.observation.EmbeddingModelObservationConvention; +import org.springframework.ai.model.SpringAIModelProperties; +import org.springframework.ai.model.SpringAIModels; +import org.springframework.ai.retry.autoconfigure.SpringAiRetryAutoConfiguration; +import org.springframework.beans.factory.ObjectProvider; +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.retry.support.RetryTemplate; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; +import org.springframework.web.client.ResponseErrorHandler; +import org.springframework.web.client.RestClient; + +/** + * Embedding {@link AutoConfiguration Auto-configuration} for Cohere + * + * @author Ricken Bazolo + */ +@AutoConfiguration(after = { RestClientAutoConfiguration.class, SpringAiRetryAutoConfiguration.class }) +@EnableConfigurationProperties({ CohereCommonProperties.class, CohereEmbeddingProperties.class }) +@ConditionalOnClass(CohereApi.class) +@ConditionalOnProperty(name = SpringAIModelProperties.EMBEDDING_MODEL, havingValue = SpringAIModels.COHERE, + matchIfMissing = true) +public class CohereEmbeddingAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + public CohereEmbeddingModel mistralAiEmbeddingModel(CohereCommonProperties commonProperties, + CohereEmbeddingProperties embeddingProperties, + ObjectProvider restClientBuilderProvider, RetryTemplate retryTemplate, + ResponseErrorHandler responseErrorHandler, ObjectProvider observationRegistry, + ObjectProvider observationConvention) { + + var cohereApi = cohereApi(embeddingProperties.getApiKey(), commonProperties.getApiKey(), + embeddingProperties.getBaseUrl(), commonProperties.getBaseUrl(), + restClientBuilderProvider.getIfAvailable(RestClient::builder), responseErrorHandler); + + var embeddingModel = CohereEmbeddingModel.builder() + .cohereApi(cohereApi) + .metadataMode(embeddingProperties.getMetadataMode()) + .options(embeddingProperties.getOptions()) + .retryTemplate(retryTemplate) + .observationRegistry(observationRegistry.getIfUnique(() -> ObservationRegistry.NOOP)) + .build(); + + observationConvention.ifAvailable(embeddingModel::setObservationConvention); + + return embeddingModel; + } + + private CohereApi cohereApi(String apiKey, String commonApiKey, String baseUrl, String commonBaseUrl, + RestClient.Builder restClientBuilder, ResponseErrorHandler responseErrorHandler) { + + var resolvedApiKey = StringUtils.hasText(apiKey) ? apiKey : commonApiKey; + var resoledBaseUrl = StringUtils.hasText(baseUrl) ? baseUrl : commonBaseUrl; + + Assert.hasText(resolvedApiKey, "Cohere API key must be set"); + Assert.hasText(resoledBaseUrl, "Cohere base URL must be set"); + + return CohereApi.builder() + .baseUrl(resoledBaseUrl) + .apiKey(resolvedApiKey) + .restClientBuilder(restClientBuilder) + .responseErrorHandler(responseErrorHandler) + .build(); + } + +} diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/java/org/springframework/ai/cohere/autoconfigure/CohereEmbeddingProperties.java b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/java/org/springframework/ai/cohere/autoconfigure/CohereEmbeddingProperties.java new file mode 100644 index 00000000000..3578ffd7079 --- /dev/null +++ b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/java/org/springframework/ai/cohere/autoconfigure/CohereEmbeddingProperties.java @@ -0,0 +1,67 @@ +/* + * 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.cohere.autoconfigure; + +import org.springframework.ai.cohere.api.CohereApi; +import org.springframework.ai.cohere.api.CohereApi.EmbeddingType; +import org.springframework.ai.cohere.api.CohereApi.EmbeddingModel; +import org.springframework.ai.cohere.embedding.CohereEmbeddingOptions; +import org.springframework.ai.document.MetadataMode; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.NestedConfigurationProperty; + +import java.util.List; + +/** + * Configuration properties for Cohere embedding model. + * + * @author Ricken Bazolo + */ +@ConfigurationProperties(CohereEmbeddingProperties.CONFIG_PREFIX) +public class CohereEmbeddingProperties extends CohereParentProperties { + + public static final String CONFIG_PREFIX = "spring.ai.cohere.embedding"; + + public static final String DEFAULT_EMBEDDING_MODEL = EmbeddingModel.EMBED_V4.getValue(); + + public static final String DEFAULT_ENCODING_FORMAT = EmbeddingType.FLOAT.name(); + + public MetadataMode metadataMode = MetadataMode.EMBED; + + @NestedConfigurationProperty + private final CohereEmbeddingOptions options = CohereEmbeddingOptions.builder() + .model(DEFAULT_EMBEDDING_MODEL) + .embeddingTypes(List.of(EmbeddingType.valueOf(DEFAULT_ENCODING_FORMAT))) + .build(); + + public CohereEmbeddingProperties() { + super.setBaseUrl(CohereCommonProperties.DEFAULT_BASE_URL); + } + + public CohereEmbeddingOptions getOptions() { + return this.options; + } + + public MetadataMode getMetadataMode() { + return this.metadataMode; + } + + public void setMetadataMode(MetadataMode metadataMode) { + this.metadataMode = metadataMode; + } + +} diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index c59639d63f7..8ac66e25bce 100644 --- a/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -14,3 +14,4 @@ # limitations under the License. # org.springframework.ai.cohere.autoconfigure.CohereChatAutoConfiguration +org.springframework.ai.cohere.autoconfigure.CohereEmbeddingAutoConfiguration diff --git a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/api/CohereApi.java b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/api/CohereApi.java index cdf478be581..3c012018ccc 100644 --- a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/api/CohereApi.java +++ b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/api/CohereApi.java @@ -20,23 +20,23 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; +import org.springframework.ai.embedding.Embedding; import org.springframework.ai.model.ChatModelDescription; import org.springframework.ai.model.ModelOptionsUtils; import org.springframework.ai.observation.conventions.AiProvider; import org.springframework.ai.retry.RetryUtils; +import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; import org.springframework.web.client.ResponseErrorHandler; import org.springframework.web.client.RestClient; import org.springframework.web.reactive.function.client.WebClient; -import reactor.core.publisher.Flux; -import reactor.core.publisher.Mono; import java.util.List; import java.util.Map; -import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Consumer; import java.util.function.Predicate; @@ -867,7 +867,7 @@ public enum EmbeddingModel { * A model that allows for text and images to be classified or turned into embeddings * dimensional - [256, 512, 1024, 1536 (default)] */ - EMBED("embed-v4.0"), + EMBED_V4("embed-v4.0"), /** * Embed v3 Multilingual model for text embeddings. * Produces 1024-dimensional embeddings suitable for multilingual semantic search, @@ -906,6 +906,266 @@ public String getValue() { } + + /** + * Embedding type + */ + public enum EmbeddingType { + /** + * Use this when you want to get back the default float embeddings. Supported with all Embed models. + */ + @JsonProperty("float") + FLOAT, + + /** + * Use this when you want to get back signed int8 embeddings. Supported with Embed v3.0 and newer Embed models. + */ + @JsonProperty("int8") + INT8, + + /** + * Use this when you want to get back unsigned int8 embeddings. Supported with Embed v3.0 and newer Embed models. + */ + @JsonProperty("uint8") + UINT8, + + /** + * Use this when you want to get back signed binary embeddings. Supported with Embed v3.0 and newer Embed models. + */ + @JsonProperty("binary") + BINARY, + + /** + * Use this when you want to get back unsigned binary embeddings. Supported with Embed v3.0 and newer Embed models. + */ + @JsonProperty("ubinary") + UBINARY, + + /** + * Use this when you want to get back base64 embeddings. Supported with Embed v3.0 and newer Embed models. + */ + @JsonProperty("base64") + BASE64 + } + + /** + * Embedding request. + * + * @param texts An array of strings to embed. + * @param model The model to use for embedding. + * @param inputType The type of input (search_document, search_query, classification, + * clustering). + * @param embeddingTypes The types of embeddings to return (float, int8, uint8, + * binary, ubinary). + * @param truncate How to handle inputs longer than the maximum token length (NONE, + * START, END). + * @param Type of the input (String or List of tokens). + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public record EmbeddingRequest( + // @formatter:off + @JsonProperty("texts") List texts, + @JsonProperty("model") String model, + @JsonProperty("input_type") InputType inputType, + @JsonProperty("embedding_types") List embeddingTypes, + @JsonProperty("truncate") Truncate truncate) { + // @formatter:on + + public static Builder builder() { + return new Builder<>(); + } + + public static final class Builder { + + private String model = EmbeddingModel.EMBED_V4.getValue(); + private List texts; + private InputType inputType = InputType.SEARCH_DOCUMENT; + private List embeddingTypes = List.of(EmbeddingType.FLOAT); + private Truncate truncate = Truncate.END; + + public Builder model(String model) { + this.model = model; + return this; + } + + public Builder texts(Object raw) { + if (raw == null) { + this.texts = null; + return this; + } + + if (raw instanceof List list) { + this.texts = (List) list; + } else { + this.texts = List.of((T) raw); + } + return this; + } + + public Builder inputType(InputType inputType) { + this.inputType = inputType; + return this; + } + + public Builder embeddingTypes(List embeddingTypes) { + this.embeddingTypes = embeddingTypes; + return this; + } + + public Builder truncate(Truncate truncate) { + this.truncate = truncate; + return this; + } + + public EmbeddingRequest build() { + return new EmbeddingRequest<>( + texts, + model, + inputType, + embeddingTypes, + truncate + ); + } + } + + /** + * Input type for embeddings. + */ + public enum InputType { + + // @formatter:off + @JsonProperty("search_document") + SEARCH_DOCUMENT, + @JsonProperty("search_query") + SEARCH_QUERY, + @JsonProperty("classification") + CLASSIFICATION, + @JsonProperty("clustering") + CLUSTERING, + @JsonProperty("image") + IMAGE + // @formatter:on + } + + /** + * Truncation strategy for inputs longer than maximum token length. + */ + public enum Truncate { + + // @formatter:off + @JsonProperty("NONE") + NONE, + @JsonProperty("START") + START, + @JsonProperty("END") + END + // @formatter:on + + } + + } + + /** + * Embedding response. + * + * @param id Unique identifier for the embedding request. + * @param embeddings The embeddings + * @param texts The texts that were embedded. + * @param responseType The type of response ("embeddings_floats" or "embeddings_by_type"). + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + @JsonIgnoreProperties(ignoreUnknown = true) + public record EmbeddingResponse( + // @formatter:off + @JsonProperty("id") String id, + @JsonProperty("embeddings") Object embeddings, + @JsonProperty("texts") List texts, + @JsonProperty("response_type") String responseType) { + // @formatter:on + + /** + * Extracts float embeddings from the response. + * Handles both response formats: + * - "embeddings_floats": embeddings is List<List<Double>> + * - "embeddings_by_type": embeddings is Map with "float" key containing List<List<Double>> + * @return List of float arrays representing the embeddings + */ + @JsonIgnore + @SuppressWarnings("unchecked") + public List getFloatEmbeddings() { + if (this.embeddings == null) { + return List.of(); + } + + // Handle "embeddings_floats" format: embeddings is directly List> + if (this.embeddings instanceof List embeddingsList) { + return embeddingsList.stream() + .map(embedding -> { + if (embedding instanceof List embeddingVector) { + float[] floatArray = new float[embeddingVector.size()]; + for (int i = 0; i < embeddingVector.size(); i++) { + Object value = embeddingVector.get(i); + floatArray[i] = (value instanceof Number number) ? number.floatValue() : 0f; + } + return floatArray; + } + return new float[0]; + }) + .toList(); + } + + // Handle "embeddings_by_type" format: embeddings is Map + if (this.embeddings instanceof Map embeddingsMap) { + Object floatEmbeddings = embeddingsMap.get("float"); + if (floatEmbeddings instanceof List embeddingsList) { + return embeddingsList.stream() + .map(embedding -> { + if (embedding instanceof List embeddingVector) { + float[] floatArray = new float[embeddingVector.size()]; + for (int i = 0; i < embeddingVector.size(); i++) { + Object value = embeddingVector.get(i); + floatArray[i] = (value instanceof Number number) ? number.floatValue() : 0f; + } + return floatArray; + } + return new float[0]; + }) + .toList(); + } + } + + return List.of(); + } + + } + + /** + * Creates an embedding vector representing the input text or token array. + * @param embeddingRequest The embedding request. + * @return Returns {@link EmbeddingResponse} with embeddings data. + * @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")} 
+ */ + public ResponseEntity embeddings(EmbeddingRequest embeddingRequest) { + + Assert.notNull(embeddingRequest, "The request body can not be null."); + + Assert.isTrue(!CollectionUtils.isEmpty(embeddingRequest.texts), "The texts list can not be empty."); + Assert.isTrue(embeddingRequest.texts.size() <= 96, "The list must be 96 items or less"); + + return this.restClient.post() + .uri("/v2/embed") + .body(embeddingRequest) + .retrieve() + .toEntity(new ParameterizedTypeReference<>() { + + }); + } + public static Builder builder() { return new Builder(); } diff --git a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/embedding/CohereEmbeddingModel.java b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/embedding/CohereEmbeddingModel.java new file mode 100644 index 00000000000..737a6eec1ca --- /dev/null +++ b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/embedding/CohereEmbeddingModel.java @@ -0,0 +1,244 @@ +/* + * Copyright 2023-2025 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.cohere.embedding; + +import io.micrometer.observation.ObservationRegistry; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.cohere.api.CohereApi; +import org.springframework.ai.document.Document; +import org.springframework.ai.document.MetadataMode; +import org.springframework.ai.embedding.*; +import org.springframework.ai.embedding.observation.DefaultEmbeddingModelObservationConvention; +import org.springframework.ai.embedding.observation.EmbeddingModelObservationContext; +import org.springframework.ai.embedding.observation.EmbeddingModelObservationConvention; +import org.springframework.ai.embedding.observation.EmbeddingModelObservationDocumentation; +import org.springframework.ai.model.ModelOptionsUtils; +import org.springframework.ai.retry.RetryUtils; +import org.springframework.retry.support.RetryTemplate; +import org.springframework.util.Assert; + +import java.util.List; +import java.util.Map; + +/** + * Provides the Cohere Embedding Model. + * + * @see AbstractEmbeddingModel + * @author Ricken Bazolo + */ +public class CohereEmbeddingModel extends AbstractEmbeddingModel { + + private static final Logger logger = LoggerFactory.getLogger(CohereEmbeddingModel.class); + + /** + * Known embedding dimensions for Cohere models. Maps model names to their respective + * embedding vector dimensions. This allows the dimensions() method to return the + * correct value without making an API call. + */ + private static final Map KNOWN_EMBEDDING_DIMENSIONS = Map.of( + CohereApi.EmbeddingModel.EMBED_MULTILINGUAL_V3.getValue(), 1024, + CohereApi.EmbeddingModel.EMBED_ENGLISH_V3.getValue(), 1024, + CohereApi.EmbeddingModel.EMBED_MULTILINGUAL_LIGHT_V3.getValue(), 384, + CohereApi.EmbeddingModel.EMBED_ENGLISH_LIGHT_V3.getValue(), 384, + CohereApi.EmbeddingModel.EMBED_V4.getValue(), 1536); + + private static final EmbeddingModelObservationConvention DEFAULT_OBSERVATION_CONVENTION = new DefaultEmbeddingModelObservationConvention(); + + private final CohereEmbeddingOptions defaultOptions; + + private final MetadataMode metadataMode; + + private final CohereApi cohereApi; + + private final RetryTemplate retryTemplate; + + /** + * Observation registry used for instrumentation. + */ + private final ObservationRegistry observationRegistry; + + /** + * Conventions to use for generating observations. + */ + private EmbeddingModelObservationConvention observationConvention = DEFAULT_OBSERVATION_CONVENTION; + + public CohereEmbeddingModel(CohereApi cohereApi, MetadataMode metadataMode, CohereEmbeddingOptions options, + RetryTemplate retryTemplate, ObservationRegistry observationRegistry) { + Assert.notNull(cohereApi, "cohereApi must not be null"); + Assert.notNull(metadataMode, "metadataMode must not be null"); + Assert.notNull(options, "options must not be null"); + Assert.notNull(retryTemplate, "retryTemplate must not be null"); + Assert.notNull(observationRegistry, "observationRegistry must not be null"); + + this.cohereApi = cohereApi; + this.metadataMode = metadataMode; + this.defaultOptions = options; + this.retryTemplate = retryTemplate; + this.observationRegistry = observationRegistry; + } + + @Override + public EmbeddingResponse call(EmbeddingRequest request) { + + var apiRequest = createRequest(request); + + EmbeddingModelObservationContext observationContext = EmbeddingModelObservationContext.builder() + .embeddingRequest(request) + .provider(CohereApi.PROVIDER_NAME) + .build(); + + return EmbeddingModelObservationDocumentation.EMBEDDING_MODEL_OPERATION + .observation(this.observationConvention, DEFAULT_OBSERVATION_CONVENTION, () -> observationContext, + this.observationRegistry) + .observe(() -> { + + var apiEmbeddingResponse = this.retryTemplate + .execute(ctx -> this.cohereApi.embeddings(apiRequest).getBody()); + + if (apiEmbeddingResponse == null) { + logger.warn("No embeddings returned for request: {}", request); + return new EmbeddingResponse(List.of()); + } + + var metadata = generateResponseMetadata(apiEmbeddingResponse.responseType()); + + // Extract float embeddings from the response using the helper method + List floatEmbeddings = apiEmbeddingResponse.getFloatEmbeddings(); + + // Map to Spring AI Embedding objects with proper indexing + List embeddings = new java.util.ArrayList<>(); + for (int i = 0; i < floatEmbeddings.size(); i++) { + embeddings.add(new Embedding(floatEmbeddings.get(i), i)); + } + + var embeddingResponse = new EmbeddingResponse(embeddings, metadata); + + observationContext.setResponse(embeddingResponse); + + return embeddingResponse; + + }); + } + + @Override + public float[] embed(Document document) { + Assert.notNull(document, "Document must not be null"); + return this.embed(document.getFormattedContent(this.metadataMode)); + } + + private EmbeddingResponseMetadata generateResponseMetadata(String model) { + return new EmbeddingResponseMetadata(model, null); + } + + /** + * Use the provided convention for reporting observation data + * @param observationConvention The provided convention + */ + public void setObservationConvention(EmbeddingModelObservationConvention observationConvention) { + Assert.notNull(observationConvention, "observationConvention cannot be null"); + this.observationConvention = observationConvention; + } + + private CohereApi.EmbeddingRequest createRequest(EmbeddingRequest request) { + CohereEmbeddingOptions options = mergeOptions(request.getOptions(), this.defaultOptions); + + return CohereApi.EmbeddingRequest.builder() + .model(options.getModel()) + .inputType(options.getInputType()) + .embeddingTypes(options.getEmbeddingTypes()) + .texts(request.getInstructions()) + .truncate(options.getTruncate()) + .build(); + } + + private CohereEmbeddingOptions mergeOptions(EmbeddingOptions requestOptions, + CohereEmbeddingOptions defaultOptions) { + CohereEmbeddingOptions options = (requestOptions != null) + ? ModelOptionsUtils.merge(requestOptions, defaultOptions, CohereEmbeddingOptions.class) + : defaultOptions; + + if (options == null) { + throw new IllegalArgumentException("Embedding options must not be null"); + } + + return options; + } + + private CohereEmbeddingOptions buildRequestOptions(EmbeddingRequest request) { + return mergeOptions(request.getOptions(), this.defaultOptions); + } + + @Override + public int dimensions() { + String model = this.defaultOptions.getModel(); + if (model == null) { + return KNOWN_EMBEDDING_DIMENSIONS.get(CohereApi.EmbeddingModel.EMBED_V4.getValue()); + } + return KNOWN_EMBEDDING_DIMENSIONS.getOrDefault(model, 1024); + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + + private CohereApi cohereApi; + + private MetadataMode metadataMode = MetadataMode.EMBED; + + private CohereEmbeddingOptions options = CohereEmbeddingOptions.builder() + .model(CohereApi.EmbeddingModel.EMBED_MULTILINGUAL_LIGHT_V3.getValue()) + .build(); + + private RetryTemplate retryTemplate = RetryUtils.DEFAULT_RETRY_TEMPLATE; + + private ObservationRegistry observationRegistry = ObservationRegistry.NOOP; + + public Builder cohereApi(CohereApi cohereApi) { + this.cohereApi = cohereApi; + return this; + } + + public Builder metadataMode(MetadataMode metadataMode) { + this.metadataMode = metadataMode; + return this; + } + + public Builder options(CohereEmbeddingOptions options) { + this.options = options; + return this; + } + + public Builder retryTemplate(RetryTemplate retryTemplate) { + this.retryTemplate = retryTemplate; + return this; + } + + public Builder observationRegistry(ObservationRegistry observationRegistry) { + this.observationRegistry = observationRegistry; + return this; + } + + public CohereEmbeddingModel build() { + return new CohereEmbeddingModel(this.cohereApi, this.metadataMode, this.options, this.retryTemplate, + this.observationRegistry); + } + } + +} diff --git a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/embedding/CohereEmbeddingOptions.java b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/embedding/CohereEmbeddingOptions.java new file mode 100644 index 00000000000..c664f000dd8 --- /dev/null +++ b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/embedding/CohereEmbeddingOptions.java @@ -0,0 +1,185 @@ +/* + * Copyright 2023-2025 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.cohere.embedding; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import org.springframework.ai.cohere.api.CohereApi; +import org.springframework.ai.cohere.api.CohereApi.EmbeddingRequest.InputType; +import org.springframework.ai.cohere.api.CohereApi.EmbeddingRequest.Truncate; +import org.springframework.ai.cohere.api.CohereApi.EmbeddingType; +import org.springframework.ai.embedding.EmbeddingOptions; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * Options for the Cohere Embedding API. + * + * @author Ricken Bazolo + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class CohereEmbeddingOptions implements EmbeddingOptions { + + /** + * ID of the model to use + */ + @JsonProperty("model") + private String model; + + /** + * The type of input (search_document, search_query, classification, clustering). + */ + @JsonProperty("input_type") + private InputType inputType; + + /** + * The types of embeddings to return (float, int8, uint8, binary, ubinary). + */ + @JsonProperty("embedding_types") + private List embeddingTypes; + + /** + * How to handle inputs longer than the maximum token length (NONE, START, END). + */ + @JsonProperty("truncate") + private Truncate truncate; + + public static Builder builder() { + return new Builder(); + } + + public static CohereEmbeddingOptions fromOptions(CohereEmbeddingOptions fromOptions) { + return builder().model(fromOptions.getModel()) + .inputType(fromOptions.getInputType()) + .embeddingTypes( + fromOptions.getEmbeddingTypes() != null ? new ArrayList<>(fromOptions.getEmbeddingTypes()) : null) + .truncate(fromOptions.getTruncate()) + .build(); + } + + @Override + public String getModel() { + return this.model; + } + + public void setModel(String model) { + this.model = model; + } + + public InputType getInputType() { + return this.inputType; + } + + public void setInputType(InputType inputType) { + this.inputType = inputType; + } + + public List getEmbeddingTypes() { + return this.embeddingTypes; + } + + public void setEmbeddingTypes(List embeddingTypes) { + this.embeddingTypes = embeddingTypes; + } + + public Truncate getTruncate() { + return this.truncate; + } + + public void setTruncate(Truncate truncate) { + this.truncate = truncate; + } + + @Override + public Integer getDimensions() { + // Cohere embeddings have fixed dimensions based on model + // embed-multilingual-v3 and embed-english-v3: 1024 + // This should be handled by the model implementation + return null; + } + + public CohereEmbeddingOptions copy() { + return fromOptions(this); + } + + @Override + public int hashCode() { + return Objects.hash(this.model, this.inputType, this.embeddingTypes, this.truncate); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + + CohereEmbeddingOptions that = (CohereEmbeddingOptions) o; + + return Objects.equals(this.model, that.model) && Objects.equals(this.inputType, that.inputType) + && Objects.equals(this.embeddingTypes, that.embeddingTypes) + && Objects.equals(this.truncate, that.truncate); + } + + public static final class Builder { + + private CohereEmbeddingOptions options; + + public Builder() { + this.options = new CohereEmbeddingOptions(); + } + + public Builder(CohereEmbeddingOptions options) { + this.options = options; + } + + public Builder model(String model) { + this.options.model = model; + return this; + } + + public Builder model(CohereApi.EmbeddingModel model) { + this.options.model = model.getValue(); + return this; + } + + public Builder inputType(InputType inputType) { + this.options.inputType = inputType; + return this; + } + + public Builder embeddingTypes(List embeddingTypes) { + this.options.embeddingTypes = embeddingTypes; + return this; + } + + public Builder truncate(Truncate truncate) { + this.options.truncate = truncate; + return this; + } + + public CohereEmbeddingOptions build() { + return this.options; + } + + } + +} From 5a123b1dc530b90d4de62cb8172e91182e938009 Mon Sep 17 00:00:00 2001 From: Ricken Bazolo Date: Sun, 16 Nov 2025 19:54:49 +0100 Subject: [PATCH 08/18] added cohere support :: test embedding Signed-off-by: ricken07 --- .../CohereAutoConfigurationIT.java | 27 +++++++- .../CohereModelConfigurationTests.java | 51 +++++++++++--- .../autoconfigure/CoherePropertiesTests.java | 67 +++++++++++++++++++ .../ai/cohere/api/CohereApiIT.java | 14 ++++ 4 files changed, 148 insertions(+), 11 deletions(-) diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/test/java/org/springframework/ai/cohere/autoconfigure/CohereAutoConfigurationIT.java b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/test/java/org/springframework/ai/cohere/autoconfigure/CohereAutoConfigurationIT.java index 63b6bccbaaf..3e4a66aa9dd 100644 --- a/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/test/java/org/springframework/ai/cohere/autoconfigure/CohereAutoConfigurationIT.java +++ b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/test/java/org/springframework/ai/cohere/autoconfigure/CohereAutoConfigurationIT.java @@ -5,9 +5,14 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; import org.springframework.ai.cohere.chat.CohereChatModel; +import org.springframework.ai.cohere.embedding.CohereEmbeddingModel; +import org.springframework.ai.embedding.EmbeddingResponse; +import org.springframework.ai.utils.SpringAiTestAutoConfigurations; import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import java.util.List; + import static org.assertj.core.api.Assertions.assertThat; /** @@ -19,7 +24,8 @@ public class CohereAutoConfigurationIT { private static final Log logger = LogFactory.getLog(CohereAutoConfigurationIT.class); private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() - .withPropertyValues("spring.ai.cohere.apiKey=" + System.getenv("COHERE_API_KEY")); + .withPropertyValues("spring.ai.cohere.apiKey=" + System.getenv("COHERE_API_KEY")) + .withConfiguration(SpringAiTestAutoConfigurations.of(CohereChatAutoConfiguration.class)); @Test void generate() { @@ -31,4 +37,23 @@ void generate() { }); } + @Test + void embedding() { + this.contextRunner + .withConfiguration(SpringAiTestAutoConfigurations.of(CohereEmbeddingAutoConfiguration.class)) + .run(context -> { + CohereEmbeddingModel embeddingModel = context.getBean(CohereEmbeddingModel.class); + + EmbeddingResponse embeddingResponse = embeddingModel + .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(embeddingModel.dimensions()).isEqualTo(1536); + }); + } + } diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/test/java/org/springframework/ai/cohere/autoconfigure/CohereModelConfigurationTests.java b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/test/java/org/springframework/ai/cohere/autoconfigure/CohereModelConfigurationTests.java index 8c457fc4f94..aa28ef1781b 100644 --- a/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/test/java/org/springframework/ai/cohere/autoconfigure/CohereModelConfigurationTests.java +++ b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/test/java/org/springframework/ai/cohere/autoconfigure/CohereModelConfigurationTests.java @@ -2,7 +2,8 @@ import org.junit.jupiter.api.Test; import org.springframework.ai.cohere.chat.CohereChatModel; -import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.ai.cohere.embedding.CohereEmbeddingModel; +import org.springframework.ai.utils.SpringAiTestAutoConfigurations; import org.springframework.boot.test.context.runner.ApplicationContextRunner; import static org.assertj.core.api.Assertions.assertThat; @@ -14,22 +15,52 @@ */ public class CohereModelConfigurationTests { - private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() - .withPropertyValues("spring.ai.cohere.apiKey=" + System.getenv("COHERE_API_KEY")); + private final ApplicationContextRunner chatContextRunner = new ApplicationContextRunner() + .withPropertyValues("spring.ai.cohere.apiKey=" + System.getenv("COHERE_API_KEY")) + .withConfiguration(SpringAiTestAutoConfigurations.of(CohereChatAutoConfiguration.class)); + + private final ApplicationContextRunner embeddingContextRunner = new ApplicationContextRunner() + .withPropertyValues("spring.ai.cohere.apiKey=" + System.getenv("COHERE_API_KEY")) + .withConfiguration(SpringAiTestAutoConfigurations.of(CohereEmbeddingAutoConfiguration.class)); @Test void chatModelActivation() { - this.contextRunner.withConfiguration(AutoConfigurations.of(CohereChatAutoConfiguration.class)).run(context -> { + this.chatContextRunner.run(context -> { assertThat(context.getBeansOfType(CohereChatProperties.class)).isNotEmpty(); assertThat(context.getBeansOfType(CohereChatModel.class)).isNotEmpty(); + assertThat(context.getBeansOfType(CohereEmbeddingProperties.class)).isEmpty(); + assertThat(context.getBeansOfType(CohereEmbeddingModel.class)).isEmpty(); }); - this.contextRunner.withConfiguration(AutoConfigurations.of(CohereChatAutoConfiguration.class)) - .withPropertyValues("spring.ai.model.chat=none") - .run(context -> { - assertThat(context.getBeansOfType(CohereChatProperties.class)).isEmpty(); - assertThat(context.getBeansOfType(CohereChatModel.class)).isEmpty(); - }); + this.chatContextRunner.withPropertyValues("spring.ai.model.chat=none", "spring.ai.model.embedding=none") + .run(context -> { + assertThat(context.getBeansOfType(CohereChatProperties.class)).isEmpty(); + assertThat(context.getBeansOfType(CohereChatModel.class)).isEmpty(); + }); + + this.chatContextRunner.withPropertyValues("spring.ai.model.chat=cohere", "spring.ai.model.embedding=none") + .run(context -> { + assertThat(context.getBeansOfType(CohereChatProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(CohereChatModel.class)).isNotEmpty(); + assertThat(context.getBeansOfType(CohereEmbeddingProperties.class)).isEmpty(); + assertThat(context.getBeansOfType(CohereEmbeddingModel.class)).isEmpty(); + }); + } + + @Test + void embeddingModelActivation() { + this.embeddingContextRunner + .run(context -> assertThat(context.getBeansOfType(CohereEmbeddingModel.class)).isNotEmpty()); + + this.embeddingContextRunner.withPropertyValues("spring.ai.model.embedding=none").run(context -> { + assertThat(context.getBeansOfType(CohereEmbeddingProperties.class)).isEmpty(); + assertThat(context.getBeansOfType(CohereEmbeddingModel.class)).isEmpty(); + }); + + this.embeddingContextRunner.withPropertyValues("spring.ai.model.embedding=cohere").run(context -> { + assertThat(context.getBeansOfType(CohereEmbeddingProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(CohereEmbeddingModel.class)).isNotEmpty(); + }); } } diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/test/java/org/springframework/ai/cohere/autoconfigure/CoherePropertiesTests.java b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/test/java/org/springframework/ai/cohere/autoconfigure/CoherePropertiesTests.java index 0b204ef4163..b6da9dc58b6 100644 --- a/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/test/java/org/springframework/ai/cohere/autoconfigure/CoherePropertiesTests.java +++ b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/test/java/org/springframework/ai/cohere/autoconfigure/CoherePropertiesTests.java @@ -3,6 +3,7 @@ import org.junit.jupiter.api.Test; import org.springframework.ai.cohere.api.CohereApi; import org.springframework.ai.retry.autoconfigure.SpringAiRetryAutoConfiguration; +import org.springframework.ai.utils.SpringAiTestAutoConfigurations; import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration; import org.springframework.boot.test.context.runner.ApplicationContextRunner; @@ -60,4 +61,70 @@ public void chatOptionsTest() { }); } + @Test + public void embeddingProperties() { + + new ApplicationContextRunner() + .withPropertyValues("spring.ai.cohere.base-url=TEST_BASE_URL", "spring.ai.cohere.api-key=abc123", + "spring.ai.cohere.embedding.options.model=MODEL_XYZ") + .withConfiguration(SpringAiTestAutoConfigurations.of(CohereEmbeddingAutoConfiguration.class)) + .run(context -> { + var embeddingProperties = context.getBean(CohereEmbeddingProperties.class); + var connectionProperties = context.getBean(CohereCommonProperties.class); + + assertThat(connectionProperties.getApiKey()).isEqualTo("abc123"); + assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL"); + + assertThat(embeddingProperties.getApiKey()).isNull(); + assertThat(embeddingProperties.getBaseUrl()).isEqualTo(CohereCommonProperties.DEFAULT_BASE_URL); + + assertThat(embeddingProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); + }); + } + + @Test + public void embeddingOverrideConnectionProperties() { + + new ApplicationContextRunner().withPropertyValues("spring.ai.cohere.base-url=TEST_BASE_URL", + "spring.ai.cohere.api-key=abc123", "spring.ai.cohere.embedding.base-url=TEST_BASE_URL2", + "spring.ai.cohere.embedding.api-key=456", "spring.ai.cohere.embedding.options.model=MODEL_XYZ") + .withConfiguration(SpringAiTestAutoConfigurations.of(CohereEmbeddingAutoConfiguration.class)) + .run(context -> { + var embeddingProperties = context.getBean(CohereEmbeddingProperties.class); + var connectionProperties = context.getBean(CohereCommonProperties.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.cohere.api-key=API_KEY", "spring.ai.cohere.base-url=TEST_BASE_URL", + "spring.ai.cohere.embedding.options.model=MODEL_XYZ", + "spring.ai.cohere.embedding.options.embedding-types[0]=FLOAT", + "spring.ai.cohere.embedding.options.input-type=search_document", + "spring.ai.cohere.embedding.options.truncate=END") + .withConfiguration(SpringAiTestAutoConfigurations.of(CohereEmbeddingAutoConfiguration.class)) + .run(context -> { + var connectionProperties = context.getBean(CohereCommonProperties.class); + var embeddingProperties = context.getBean(CohereEmbeddingProperties.class); + + assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL"); + assertThat(connectionProperties.getApiKey()).isEqualTo("API_KEY"); + + assertThat(embeddingProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); + assertThat(embeddingProperties.getOptions().getEmbeddingTypes().get(0).name()).isEqualTo("FLOAT"); + assertThat(embeddingProperties.getOptions().getTruncate().name()).isEqualTo("END"); + assertThat(embeddingProperties.getOptions().getInputType().name()).isEqualTo("SEARCH_DOCUMENT"); + }); + } + } diff --git a/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/api/CohereApiIT.java b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/api/CohereApiIT.java index 3902eab1d4a..699ae6115cc 100644 --- a/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/api/CohereApiIT.java +++ b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/api/CohereApiIT.java @@ -1,5 +1,6 @@ package org.springframework.ai.cohere.api; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; import org.springframework.ai.cohere.CohereTestConfiguration; @@ -50,4 +51,17 @@ void chatCompletionEntityWithSystemMessage() { assertThat(response.getBody()).isNotNull(); } + @Test + void embeddings() { + ResponseEntity response = this.cohereApi + .embeddings(CohereApi.EmbeddingRequest.builder() + .texts("Hello world") + .build()); + + assertThat(response).isNotNull(); + Assertions.assertNotNull(response.getBody()); + assertThat(response.getBody().getFloatEmbeddings()).hasSize(1); + assertThat(response.getBody().getFloatEmbeddings().get(0)).hasSize(1536); + } + } From 6df6bf09391bbd07b1e1b6fcbb69f40d45c7d08a Mon Sep 17 00:00:00 2001 From: Ricken Bazolo Date: Mon, 17 Nov 2025 19:37:54 +0100 Subject: [PATCH 09/18] added cohere support :: streaming Signed-off-by: ricken07 --- .../ai/cohere/api/CohereApi.java | 110 ++++++--- .../CohereStreamFunctionCallingHelper.java | 216 ++++++++++++++++++ .../ai/cohere/chat/CohereChatModel.java | 169 +++++++++++++- .../ai/cohere/api/CohereApiIT.java | 15 ++ 4 files changed, 476 insertions(+), 34 deletions(-) create mode 100644 models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/api/CohereStreamFunctionCallingHelper.java diff --git a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/api/CohereApi.java b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/api/CohereApi.java index 3c012018ccc..0aa5d85c263 100644 --- a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/api/CohereApi.java +++ b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/api/CohereApi.java @@ -20,7 +20,6 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; -import org.springframework.ai.embedding.Embedding; import org.springframework.ai.model.ChatModelDescription; import org.springframework.ai.model.ModelOptionsUtils; import org.springframework.ai.observation.conventions.AiProvider; @@ -34,9 +33,13 @@ import org.springframework.web.client.ResponseErrorHandler; import org.springframework.web.client.RestClient; import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; import java.util.List; import java.util.Map; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Consumer; import java.util.function.Predicate; @@ -64,7 +67,7 @@ public class CohereApi { private final WebClient webClient; - // TODO ADD Stream helper + private final CohereStreamFunctionCallingHelper chunkMerger = new CohereStreamFunctionCallingHelper(); /** * Create a new client api with DEFAULT_BASE_URL @@ -149,7 +152,7 @@ public String getName() { * Usage statistics. */ @JsonInclude(JsonInclude.Include.NON_NULL) - public record Usage(@JsonProperty("billedUnits") BilledUnits billedUnits, @JsonProperty("tokens") Tokens tokens) { + public record Usage(@JsonProperty("billedUnits") BilledUnits billedUnits, @JsonProperty("tokens") Tokens tokens, @JsonProperty("cached_tokens") Integer cachedTokens) { /** * Bille units * @@ -365,6 +368,19 @@ public ChatCompletionMessage(Object content, Role role, List toolCalls this(content, role, null, toolCalls, null); } + /** + * Get message content as String. + */ + public String content() { + if (this.rawContent == null) { + return null; + } + if (this.rawContent instanceof String text) { + return text; + } + throw new IllegalStateException("The content is not a string!"); + } + /** * An array of rawContent parts with a defined type. Each MediaContent can be of * either "text" or "image_url" type. Only one option allowed. @@ -755,6 +771,12 @@ public enum ChatCompletionFinishReason { */ TOOL_CALL, + /** + * The model called a tool. + */ + @JsonProperty("tool_calls") + TOOL_CALLS, + /** * The generation failed due to an internal error */ @@ -808,47 +830,23 @@ public ResponseEntity chatCompletionEntity(ChatCompletionRequest return this.restClient.post().uri("/v2/chat/").body(chatRequest).retrieve().toEntity(ChatCompletion.class); } - /** - * 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. - * @param usage usage metrics for the chat completion. - */ @JsonInclude(JsonInclude.Include.NON_NULL) @JsonIgnoreProperties(ignoreUnknown = true) 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, - @JsonProperty("usage") Usage usage) { + @JsonProperty("type") String type, + @JsonProperty("index") Integer index, + @JsonProperty("delta") ChunkDelta delta) { // @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. - * @param logprobs Log probability information for the choice. - */ @JsonInclude(JsonInclude.Include.NON_NULL) @JsonIgnoreProperties(ignoreUnknown = true) - public record ChunkChoice( + public record ChunkDelta( // @formatter:off - @JsonProperty("index") Integer index, - @JsonProperty("delta") ChatCompletionMessage delta, + @JsonProperty("message") ChatCompletionMessage message, @JsonProperty("finish_reason") ChatCompletionFinishReason finishReason, - @JsonProperty("logprobs") LogProbs logprobs) { + @JsonProperty("usage") Usage usage) { // @formatter:on } @@ -1166,6 +1164,52 @@ public ResponseEntity embeddings(EmbeddingRequest embe }); } + /** + * 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 stream property to true."); + + AtomicBoolean isInsideTool = new AtomicBoolean(false); + + return this.webClient.post() + .uri("v2/chat") + .body(Mono.just(chatRequest), ChatCompletionRequest.class) + .retrieve() + .bodyToFlux(String.class) + .takeUntil(SSE_DONE_PREDICATE) + .filter(SSE_DONE_PREDICATE.negate()) + .map(content -> ModelOptionsUtils.jsonToObject(content, ChatCompletionChunk.class)) + .map(chunk -> { + if (this.chunkMerger.isStreamingToolFunctionCall(chunk)) { + isInsideTool.set(true); + } + return chunk; + }) + .windowUntil(chunk -> { + if (isInsideTool.get()) { + if (this.chunkMerger.isStreamingToolFunctionCallFinish(chunk)) { + isInsideTool.set(false); + return true; + } + return !isInsideTool.get(); + } + return "message-end".equals(chunk.type()); + }) + .concatMap(window -> + window.reduce( + new ChatCompletionChunk(null, null, null, null), + this.chunkMerger::merge + ) + ) + .filter(Objects::nonNull); + } + public static Builder builder() { return new Builder(); } diff --git a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/api/CohereStreamFunctionCallingHelper.java b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/api/CohereStreamFunctionCallingHelper.java new file mode 100644 index 00000000000..65f45dad2dd --- /dev/null +++ b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/api/CohereStreamFunctionCallingHelper.java @@ -0,0 +1,216 @@ +/* + * 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.cohere.api; + +import org.springframework.ai.cohere.api.CohereApi.ChatCompletionChunk; +import org.springframework.ai.cohere.api.CohereApi.ChatCompletionMessage; +import org.springframework.ai.cohere.api.CohereApi.ChatCompletionMessage.ChatCompletionFunction; +import org.springframework.ai.cohere.api.CohereApi.ChatCompletionMessage.Role; +import org.springframework.ai.cohere.api.CohereApi.ChatCompletionMessage.ToolCall; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * @author Ricken Bazolo + */ +public class CohereStreamFunctionCallingHelper { + + /** + * Merge the previous and current ChatCompletionChunk into a single one. + * @param previous the previous ChatCompletionChunk + * @param current the current ChatCompletionChunk + * @return the merged ChatCompletionChunk + */ + public ChatCompletionChunk merge(ChatCompletionChunk previous, ChatCompletionChunk current) { + + if (previous == null) { + return current; + } + + if (current == null) { + return previous; + } + + var previousDelta = previous.delta(); + var currentDelta = current.delta(); + + ChatCompletionMessage previousMessage = previousDelta != null ? previousDelta.message() : null; + ChatCompletionMessage currentMessage = currentDelta != null ? currentDelta.message() : null; + + Role role = previousMessage != null && previousMessage.role() != null + ? previousMessage.role() + : (currentMessage != null ? currentMessage.role() : null); + + String previousText = previousMessage != null ? extractTextFromRawContent(previousMessage.rawContent()) : ""; + + String currentText = currentMessage != null ? extractTextFromRawContent(currentMessage.rawContent()) : ""; + + String mergedText = previousText + currentText; + + String toolPlan = previousMessage != null && previousMessage.toolPlan() != null + ? previousMessage.toolPlan() + : (currentMessage != null ? currentMessage.toolPlan() : null); + + List toolCalls = previousMessage != null && previousMessage.toolCalls() != null && !previousMessage.toolCalls().isEmpty() + ? previousMessage.toolCalls() + : (currentMessage != null ? currentMessage.toolCalls() : null); + + List citations = previousMessage != null && previousMessage.citations() != null && !previousMessage.citations().isEmpty() + ? previousMessage.citations() + : (currentMessage != null ? currentMessage.citations() : null); + + ChatCompletionMessage mergedMessage = new ChatCompletionMessage( + mergedText, + role, + toolPlan, + toolCalls, + citations + ); + + var finishReason = (currentDelta != null && currentDelta.finishReason() != null) + ? currentDelta.finishReason() + : (previousDelta != null ? previousDelta.finishReason() : null); + + var usage = (currentDelta != null && currentDelta.usage() != null) + ? currentDelta.usage() + : (previousDelta != null ? previousDelta.usage() : null); + + var mergedDelta = new ChatCompletionChunk.ChunkDelta( + mergedMessage, + finishReason, + usage + ); + + String id = current.id() != null ? current.id() : previous.id(); + String type = current.type() != null ? current.type() : previous.type(); + Integer index = current.index() != null ? current.index() : previous.index(); + + return new ChatCompletionChunk(id, type, index, mergedDelta); + } + + private String extractTextFromRawContent(Object rawContent) { + if (rawContent == null) { + return ""; + } + if (rawContent instanceof Map map) { + Object text = map.get("text"); + if (text != null) return text.toString(); + } + if (rawContent instanceof List list) { + StringBuilder sb = new StringBuilder(); + for (Object item : list) { + if (item instanceof Map m) { + Object text = m.get("text"); + if (text != null) sb.append(text); + } else if (item instanceof String s) { + sb.append(s); + } + } + return sb.toString(); + } + if (rawContent instanceof String s) return s; + return rawContent.toString(); + } + + + private ChatCompletionMessage merge(ChatCompletionMessage previous, ChatCompletionMessage current) { + String content = (current.content() != null ? current.content() + : (previous.content() != null) ? previous.content() : ""); + Role role = (current.role() != null ? current.role() : previous.role()); + role = (role != null ? role : Role.ASSISTANT); + //String name = (current.name() != null ? current.name() : previous.name()); + + List toolCalls = new ArrayList<>(); + ToolCall lastPreviousTooCall = null; + if (previous.toolCalls() != null) { + lastPreviousTooCall = previous.toolCalls().get(previous.toolCalls().size() - 1); + if (previous.toolCalls().size() > 1) { + toolCalls.addAll(previous.toolCalls().subList(0, previous.toolCalls().size() - 1)); + } + } + if (current.toolCalls() != null) { + if (current.toolCalls().size() > 1) { + throw new IllegalStateException("Currently only one tool call is supported per message!"); + } + var currentToolCall = current.toolCalls().iterator().next(); + if (currentToolCall.id() != null) { + if (lastPreviousTooCall != null) { + toolCalls.add(lastPreviousTooCall); + } + toolCalls.add(currentToolCall); + } + else { + toolCalls.add(merge(lastPreviousTooCall, currentToolCall)); + } + } + else { + if (lastPreviousTooCall != null) { + toolCalls.add(lastPreviousTooCall); + } + } + return new ChatCompletionMessage(content, role, toolCalls); + } + + private ToolCall merge(ToolCall previous, ToolCall current) { + if (previous == null) { + return current; + } + String id = (current.id() != null ? current.id() : previous.id()); + String type = (current.type() != null ? current.type() : previous.type()); + ChatCompletionFunction function = merge(previous.function(), current.function()); + Integer index = (current.index() != null ? current.index() : previous.index()); + return new ToolCall(id, type, function, index); + } + + private ChatCompletionFunction merge(ChatCompletionFunction previous, ChatCompletionFunction current) { + if (previous == null) { + return current; + } + String name = (current.name() != null ? current.name() : previous.name()); + StringBuilder arguments = new StringBuilder(); + if (previous.arguments() != null) { + arguments.append(previous.arguments()); + } + if (current.arguments() != null) { + arguments.append(current.arguments()); + } + return new ChatCompletionFunction(name, arguments.toString()); + } + + /** + * @param chatCompletion the ChatCompletionChunk to check + * @return true if the ChatCompletionChunk is a streaming tool function call. + */ + public boolean isStreamingToolFunctionCall(ChatCompletionChunk chatCompletion) { + + return false; + } + + /** + * @param chatCompletion the ChatCompletionChunk to check + * @return true if the ChatCompletionChunk is a streaming tool function call and it is + * the last one. + */ + public boolean isStreamingToolFunctionCallFinish(ChatCompletionChunk chatCompletion) { + + return false; + } + +} +// --- diff --git a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/chat/CohereChatModel.java b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/chat/CohereChatModel.java index ac8f3e41955..f7a86c9616b 100644 --- a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/chat/CohereChatModel.java +++ b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/chat/CohereChatModel.java @@ -16,11 +16,14 @@ package org.springframework.ai.cohere.chat; +import io.micrometer.observation.Observation; import io.micrometer.observation.ObservationRegistry; +import io.micrometer.observation.contextpropagation.ObservationThreadLocalAccessor; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.ai.chat.metadata.ChatGenerationMetadata; +import org.springframework.ai.chat.model.MessageAggregator; import org.springframework.ai.cohere.api.CohereApi; import org.springframework.ai.cohere.api.CohereApi.FunctionTool; import org.springframework.ai.cohere.api.CohereApi.ChatCompletion; @@ -52,6 +55,7 @@ import org.springframework.ai.model.tool.ToolExecutionEligibilityPredicate; import org.springframework.ai.model.tool.ToolExecutionResult; import org.springframework.ai.model.tool.ToolCallingChatOptions; +import org.springframework.ai.model.tool.internal.ToolCallReactiveContextHolder; import org.springframework.ai.retry.RetryUtils; import org.springframework.ai.support.UsageCalculator; import org.springframework.ai.tool.definition.ToolDefinition; @@ -61,11 +65,14 @@ import org.springframework.util.CollectionUtils; import org.springframework.util.MimeType; import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; import java.util.ArrayList; import java.util.Base64; import java.util.List; import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; /** * Represents a Cohere Chat Model. @@ -169,7 +176,167 @@ public ChatResponse call(Prompt prompt) { @Override public Flux stream(Prompt prompt) { Prompt requestPrompt = buildRequestPrompt(prompt); - return Flux.error(new UnsupportedOperationException("Streaming is not supported yet")); + return this.internalStream(requestPrompt, null); + } + + public Flux internalStream(Prompt prompt, ChatResponse previousChatResponse) { + return Flux.deferContextual(contextView -> { + var request = createRequest(prompt, true); + + ChatModelObservationContext observationContext = ChatModelObservationContext.builder() + .prompt(prompt) + .provider(CohereApi.PROVIDER_NAME) + .build(); + + Observation observation = ChatModelObservationDocumentation.CHAT_MODEL_OPERATION.observation( + this.observationConvention, DEFAULT_OBSERVATION_CONVENTION, () -> observationContext, + this.observationRegistry); + + observation.parentObservation(contextView.getOrDefault(ObservationThreadLocalAccessor.KEY, null)).start(); + + Flux completionChunks = this.retryTemplate + .execute(ctx -> this.cohereApi.chatCompletionStream(request)); + + // For chunked responses, only the first chunk contains the role. + // The rest of the chunks with same ID share the same role. + ConcurrentHashMap roleMap = new ConcurrentHashMap<>(); + + // Convert the ChatCompletionChunk into a ChatCompletion to be able to reuse + // the function call handling logic. + Flux chatResponse = completionChunks.map(this::toChatCompletion) + .filter(chatCompletion -> chatCompletion != null && chatCompletion.message() != null) + .switchMap(chatCompletion -> Mono.just(chatCompletion).map(completion -> { + try { + @SuppressWarnings("null") + String id = completion.id(); + ChatCompletionMessage.Provider message = completion.message(); + + // Store the role for this completion ID + if (message.role() != null) { + roleMap.putIfAbsent(id, message.role().name()); + } + + // @formatter:off + List generations = message.content().stream().map(content -> { + Map metadata = Map.of( + "id", completion.id() != null ? completion.id() : "", + "role", roleMap.getOrDefault(id, ""), + "finishReason", completion.finishReason() != null ? completion.finishReason().name() : ""); + return buildGeneration(content, completion, metadata); + }).toList(); + // @formatter:on + + if (completion.usage() != null) { + DefaultUsage usage = getDefaultUsage(completion.usage()); + Usage cumulativeUsage = UsageCalculator.getCumulativeUsage(usage, previousChatResponse); + return new ChatResponse(generations, from(completion, cumulativeUsage)); + } + else { + return new ChatResponse(generations); + } + } + catch (Exception e) { + logger.error("Error processing chat completion", e); + return new ChatResponse(List.of()); + } + })); + + // @formatter:off + Flux chatResponseFlux = chatResponse.flatMap(response -> { + if (this.toolExecutionEligibilityPredicate.isToolExecutionRequired(prompt.getOptions(), response)) { + return Flux.deferContextual(ctx -> { + ToolExecutionResult toolExecutionResult; + try { + ToolCallReactiveContextHolder.setContext(ctx); + toolExecutionResult = this.toolCallingManager.executeToolCalls(prompt, response); + } + finally { + ToolCallReactiveContextHolder.clearContext(); + } + if (toolExecutionResult.returnDirect()) { + // Return tool execution result directly to the client. + return Flux.just(ChatResponse.builder().from(response) + .generations(ToolExecutionResult.buildGenerations(toolExecutionResult)) + .build()); + } + else { + // Send the tool execution result back to the model. + return this.internalStream(new Prompt(toolExecutionResult.conversationHistory(), prompt.getOptions()), + response); + } + }).subscribeOn(Schedulers.boundedElastic()); + } + else { + return Flux.just(response); + } + }) + .doOnError(observation::error) + .doFinally(s -> observation.stop()) + .contextWrite(ctx -> ctx.put(ObservationThreadLocalAccessor.KEY, observation)); + // @formatter:on; + + return new MessageAggregator().aggregate(chatResponseFlux, observationContext::setResponse); + }); + + } + + private ChatCompletion toChatCompletion(CohereApi.ChatCompletionChunk chunk) { + if (chunk == null || chunk.delta() == null) { + return null; + } + + CohereApi.ChatCompletionChunk.ChunkDelta delta = chunk.delta(); + ChatCompletionMessage message = delta.message(); + + ChatCompletionMessage.Provider provider = null; + if (message != null) { + + List content = extractMessageContent(message.rawContent()); + + provider = new ChatCompletionMessage.Provider( + content, + message.role(), + message.toolPlan(), + message.toolCalls(), + message.citations() + ); + } + + return new CohereApi.ChatCompletion( + chunk.id(), + delta.finishReason(), + provider, + null, + delta.usage() + ); + } + + private List extractMessageContent(Object rawContent) { + if (rawContent == null) { + return List.of(); + } + + if (rawContent instanceof String text) { + return List.of(new ChatCompletionMessage.MessageContent("text", text, null)); + } + + if (rawContent instanceof List list) { + List messageContents = new ArrayList<>(); + for (Object item : list) { + if (item instanceof ChatCompletionMessage.MessageContent mc) { + messageContents.add(mc); + } + else if (item instanceof Map map) { + String type = (String) map.get("type"); + String text = (String) map.get("text"); + Object value = map.get("value"); + messageContents.add(new ChatCompletionMessage.MessageContent(type, text, value)); + } + } + return messageContents; + } + + return List.of(); } Prompt buildRequestPrompt(Prompt prompt) { diff --git a/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/api/CohereApiIT.java b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/api/CohereApiIT.java index 699ae6115cc..7d86a9d3001 100644 --- a/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/api/CohereApiIT.java +++ b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/api/CohereApiIT.java @@ -1,6 +1,7 @@ package org.springframework.ai.cohere.api; import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; import org.springframework.ai.cohere.CohereTestConfiguration; @@ -12,6 +13,7 @@ import org.springframework.ai.cohere.api.CohereApi.ChatCompletionMessage; import org.springframework.ai.cohere.api.CohereApi.ChatCompletionMessage.Role; import org.springframework.ai.cohere.api.CohereApi.ChatCompletionRequest; +import reactor.core.publisher.Flux; import java.util.List; @@ -25,6 +27,7 @@ class CohereApiIT extends AbstractIT { @Test + @Disabled void chatCompletionEntity() { ChatCompletionMessage chatCompletionMessage = new ChatCompletionMessage("Hello world", Role.USER); ResponseEntity response = this.cohereApi.chatCompletionEntity(new ChatCompletionRequest( @@ -35,6 +38,7 @@ void chatCompletionEntity() { } @Test + @Disabled void chatCompletionEntityWithSystemMessage() { ChatCompletionMessage userMessage = new ChatCompletionMessage( "Tell me about 3 famous pirates from the Golden Age of Piracy and why they did?", Role.USER); @@ -52,6 +56,7 @@ void chatCompletionEntityWithSystemMessage() { } @Test + @Disabled void embeddings() { ResponseEntity response = this.cohereApi .embeddings(CohereApi.EmbeddingRequest.builder() @@ -64,4 +69,14 @@ void embeddings() { assertThat(response.getBody().getFloatEmbeddings().get(0)).hasSize(1536); } + @Test + void chatCompletionStream() { + ChatCompletionMessage chatCompletionMessage = new ChatCompletionMessage("Hello world", Role.USER); + Flux response = this.cohereApi.chatCompletionStream(new ChatCompletionRequest( + List.of(chatCompletionMessage), CohereApi.ChatModel.COMMAND_A_R7B.getValue(), 0.8, true)); + + assertThat(response).isNotNull(); + assertThat(response.collectList().block()).isNotNull(); + } + } From c31e999794abdcd22503e1b0a133522868cc67e6 Mon Sep 17 00:00:00 2001 From: Ricken Bazolo Date: Mon, 17 Nov 2025 19:39:03 +0100 Subject: [PATCH 10/18] added cohere support :: enable api test Signed-off-by: ricken07 --- .../java/org/springframework/ai/cohere/api/CohereApiIT.java | 4 ---- 1 file changed, 4 deletions(-) diff --git a/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/api/CohereApiIT.java b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/api/CohereApiIT.java index 7d86a9d3001..ab50d002096 100644 --- a/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/api/CohereApiIT.java +++ b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/api/CohereApiIT.java @@ -1,7 +1,6 @@ package org.springframework.ai.cohere.api; import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; import org.springframework.ai.cohere.CohereTestConfiguration; @@ -27,7 +26,6 @@ class CohereApiIT extends AbstractIT { @Test - @Disabled void chatCompletionEntity() { ChatCompletionMessage chatCompletionMessage = new ChatCompletionMessage("Hello world", Role.USER); ResponseEntity response = this.cohereApi.chatCompletionEntity(new ChatCompletionRequest( @@ -38,7 +36,6 @@ void chatCompletionEntity() { } @Test - @Disabled void chatCompletionEntityWithSystemMessage() { ChatCompletionMessage userMessage = new ChatCompletionMessage( "Tell me about 3 famous pirates from the Golden Age of Piracy and why they did?", Role.USER); @@ -56,7 +53,6 @@ void chatCompletionEntityWithSystemMessage() { } @Test - @Disabled void embeddings() { ResponseEntity response = this.cohereApi .embeddings(CohereApi.EmbeddingRequest.builder() From 3f2df028dc144de441146c0d8a1b133837268c9d Mon Sep 17 00:00:00 2001 From: Ricken Bazolo Date: Mon, 17 Nov 2025 21:06:29 +0100 Subject: [PATCH 11/18] added cohere support :: fix function calling Signed-off-by: ricken07 --- .../ai/cohere/api/CohereApi.java | 11 +- .../CohereStreamFunctionCallingHelper.java | 23 ++- .../api/tool/CohereApiToolFunctionCallIT.java | 161 +++++++++++++++++ .../cohere/api/tool/MockWeatherService.java | 91 ++++++++++ .../tool/PaymentStatusFunctionCallingIT.java | 167 ++++++++++++++++++ 5 files changed, 444 insertions(+), 9 deletions(-) create mode 100644 models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/api/tool/CohereApiToolFunctionCallIT.java create mode 100644 models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/api/tool/MockWeatherService.java create mode 100644 models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/api/tool/PaymentStatusFunctionCallingIT.java diff --git a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/api/CohereApi.java b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/api/CohereApi.java index 0aa5d85c263..b56d8ce7875 100644 --- a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/api/CohereApi.java +++ b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/api/CohereApi.java @@ -356,16 +356,19 @@ public enum ToolChoice { * the {@link ChatCompletionFinishReason#TOOL_CALL} role and null otherwise. */ public record ChatCompletionMessage(@JsonProperty("content") Object rawContent, @JsonProperty("role") Role role, - // @JsonProperty("name") String name, @JsonProperty("tool_plan") String toolPlan, @JsonProperty("tool_calls") List toolCalls, - @JsonProperty("citations") List citations) { + @JsonProperty("citations") List citations, @JsonProperty("tool_call_id") String toolCallId) { public ChatCompletionMessage(Object content, Role role) { - this(content, role, null, null, null); + this(content, role, null, null, null, null); } public ChatCompletionMessage(Object content, Role role, List toolCalls) { - this(content, role, null, toolCalls, null); + this(content, role, null, toolCalls, null, null); + } + + public ChatCompletionMessage(Object content, Role role, List toolCalls, String toolPlan) { + this(content, role, toolPlan, toolCalls, null, null); } /** diff --git a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/api/CohereStreamFunctionCallingHelper.java b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/api/CohereStreamFunctionCallingHelper.java index 65f45dad2dd..00661a0b352 100644 --- a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/api/CohereStreamFunctionCallingHelper.java +++ b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/api/CohereStreamFunctionCallingHelper.java @@ -21,6 +21,7 @@ import org.springframework.ai.cohere.api.CohereApi.ChatCompletionMessage.ChatCompletionFunction; import org.springframework.ai.cohere.api.CohereApi.ChatCompletionMessage.Role; import org.springframework.ai.cohere.api.CohereApi.ChatCompletionMessage.ToolCall; +import org.springframework.util.CollectionUtils; import java.util.ArrayList; import java.util.List; @@ -75,12 +76,14 @@ public ChatCompletionChunk merge(ChatCompletionChunk previous, ChatCompletionChu ? previousMessage.citations() : (currentMessage != null ? currentMessage.citations() : null); + ChatCompletionMessage mergedMessage = new ChatCompletionMessage( mergedText, role, toolPlan, toolCalls, - citations + citations, + null ); var finishReason = (currentDelta != null && currentDelta.finishReason() != null) @@ -134,7 +137,7 @@ private ChatCompletionMessage merge(ChatCompletionMessage previous, ChatCompleti : (previous.content() != null) ? previous.content() : ""); Role role = (current.role() != null ? current.role() : previous.role()); role = (role != null ? role : Role.ASSISTANT); - //String name = (current.name() != null ? current.name() : previous.name()); + String toolPlan = (current.toolPlan() != null ? current.toolPlan() : previous.toolPlan()); List toolCalls = new ArrayList<>(); ToolCall lastPreviousTooCall = null; @@ -164,7 +167,7 @@ private ChatCompletionMessage merge(ChatCompletionMessage previous, ChatCompleti toolCalls.add(lastPreviousTooCall); } } - return new ChatCompletionMessage(content, role, toolCalls); + return new ChatCompletionMessage(content, role, toolCalls, toolPlan); } private ToolCall merge(ToolCall previous, ToolCall current) { @@ -198,8 +201,12 @@ private ChatCompletionFunction merge(ChatCompletionFunction previous, ChatComple * @return true if the ChatCompletionChunk is a streaming tool function call. */ public boolean isStreamingToolFunctionCall(ChatCompletionChunk chatCompletion) { + var delta = chatCompletion.delta(); + if (delta == null) { + return false; + } - return false; + return !CollectionUtils.isEmpty(delta.message().toolCalls()); } /** @@ -209,7 +216,13 @@ public boolean isStreamingToolFunctionCall(ChatCompletionChunk chatCompletion) { */ public boolean isStreamingToolFunctionCallFinish(ChatCompletionChunk chatCompletion) { - return false; + var delta = chatCompletion.delta(); + + if (delta == null) { + return false; + } + + return delta.finishReason() == CohereApi.ChatCompletionFinishReason.TOOL_CALLS; } } diff --git a/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/api/tool/CohereApiToolFunctionCallIT.java b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/api/tool/CohereApiToolFunctionCallIT.java new file mode 100644 index 00000000000..cead57fc0ed --- /dev/null +++ b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/api/tool/CohereApiToolFunctionCallIT.java @@ -0,0 +1,161 @@ +/* + * Copyright 2023-2025 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.cohere.api.tool; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +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.cohere.api.CohereApi; +import org.springframework.ai.cohere.api.CohereApi.ChatCompletion; +import org.springframework.ai.cohere.api.CohereApi.ChatCompletionMessage; +import org.springframework.ai.cohere.api.CohereApi.ChatCompletionMessage.Role; +import org.springframework.ai.cohere.api.CohereApi.ChatCompletionMessage.ToolCall; +import org.springframework.ai.cohere.api.CohereApi.ChatCompletionRequest; +import org.springframework.ai.cohere.api.CohereApi.ChatCompletionRequest.ToolChoice; +import org.springframework.ai.cohere.api.CohereApi.FunctionTool.Type; +import org.springframework.ai.model.ModelOptionsUtils; +import org.springframework.http.ResponseEntity; +import org.springframework.util.ObjectUtils; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Ricken Bazolo + */ +@EnabledIfEnvironmentVariable(named = "COHERE_API_KEY", matches = ".+") +public class CohereApiToolFunctionCallIT { + + static final String MISTRAL_AI_CHAT_MODEL = CohereApi.ChatModel.COMMAND_A_R7B.getValue(); + + private final Logger logger = LoggerFactory.getLogger(CohereApiToolFunctionCallIT.class); + + MockWeatherService weatherService = new MockWeatherService(); + + CohereApi completionApi = CohereApi.builder().apiKey(System.getenv("COHERE_API_KEY")).build(); + + private static T fromJson(String json, Class targetClass) { + try { + return new ObjectMapper().readValue(json, targetClass); + } + catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + + @Test + @SuppressWarnings("null") + public void toolFunctionCall() throws JsonProcessingException { + + // Step 1: send the conversation and available functions to the model + var message = new ChatCompletionMessage( + "What's the weather like in San Francisco, Tokyo, and Paris? Show the temperature in Celsius.", + Role.USER); + + var functionTool = new CohereApi.FunctionTool(Type.FUNCTION, + new CohereApi.FunctionTool.Function( + "Get the weather in location. Return temperature in 30°F or 30°C format.", "getCurrentWeather", + ModelOptionsUtils.jsonToMap(""" + { + "type": "object", + "properties": { + "location": { + "type": "string", + "description": "The city and state e.g. San Francisco, CA" + }, + "unit": { + "type": "string", + "enum": ["C", "F"] + } + }, + "required": ["location", "unit"] + } + """))); + + List messages = new ArrayList<>(List.of(message)); + + ChatCompletionRequest chatCompletionRequest = new ChatCompletionRequest(messages, MISTRAL_AI_CHAT_MODEL, + List.of(functionTool), ToolChoice.REQUIRED); + + System.out + .println(new ObjectMapper().writerWithDefaultPrettyPrinter().writeValueAsString(chatCompletionRequest)); + + ResponseEntity response = this.completionApi.chatCompletionEntity(chatCompletionRequest); + + ChatCompletion chatCompletion = response.getBody(); + + assertThat(chatCompletion).isNotNull(); + assertThat(chatCompletion.message()).isNotNull(); + + ChatCompletionMessage responseMessage = new ChatCompletionMessage(chatCompletion.message().content(), + chatCompletion.message().role(), chatCompletion.message().toolPlan(), chatCompletion.message().toolCalls(), chatCompletion.message().citations(), null); + + + assertThat(responseMessage.role()).isEqualTo(Role.ASSISTANT); + assertThat(responseMessage.toolCalls()).isNotNull(); + + // Check if the model wanted to call a function + if (!ObjectUtils.isEmpty(responseMessage.toolCalls())) { + + // extend conversation with assistant's reply. + messages.add(responseMessage); + + // Send the info for each function call and function response to the model. + for (ToolCall toolCall : responseMessage.toolCalls()) { + var functionName = toolCall.function().name(); + if ("getCurrentWeather".equals(functionName)) { + MockWeatherService.Request weatherRequest = fromJson(toolCall.function().arguments(), + MockWeatherService.Request.class); + + MockWeatherService.Response weatherResponse = this.weatherService.apply(weatherRequest); + + // extend conversation with function response. + messages.add(new ChatCompletionMessage("" + weatherResponse.temp() + weatherRequest.unit(), + Role.TOOL, functionName, null, responseMessage.citations(), toolCall.id())); + } + } + + var functionResponseRequest = new ChatCompletionRequest(messages, MISTRAL_AI_CHAT_MODEL, 0.8); + + ResponseEntity result2 = this.completionApi + .chatCompletionEntity(functionResponseRequest); + + chatCompletion = result2.getBody(); + + logger.info("Final response: {}", chatCompletion); + + assertThat(chatCompletion.message().content()).isNotEmpty(); + + var messageContent = chatCompletion.message().content().get(0); + + assertThat(chatCompletion.message().role()).isEqualTo(Role.ASSISTANT); + assertThat(messageContent.text()).contains("San Francisco") + .containsAnyOf("30.0", "30"); + assertThat(messageContent.text()).contains("Tokyo") + .containsAnyOf("10.0", "10"); + assertThat(messageContent.text()).contains("Paris") + .containsAnyOf("15.0", "15"); + } + + } + +} diff --git a/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/api/tool/MockWeatherService.java b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/api/tool/MockWeatherService.java new file mode 100644 index 00000000000..6a5be03fae0 --- /dev/null +++ b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/api/tool/MockWeatherService.java @@ -0,0 +1,91 @@ +/* + * 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.cohere.api.tool; + +import com.fasterxml.jackson.annotation.JsonClassDescription; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyDescription; + +import java.util.function.Function; + + +public class MockWeatherService implements Function { + + @Override + public Response apply(Request request) { + + double temperature = 0; + if (request.location().contains("Paris")) { + temperature = 15; + } + else if (request.location().contains("Tokyo")) { + temperature = 10; + } + else if (request.location().contains("San Francisco")) { + temperature = 30; + } + + return new Response(temperature, 15, 20, 2, 53, 45, Unit.C); + } + + /** + * Temperature units. + */ + public enum Unit { + + /** + * Celsius. + */ + C("metric"), + /** + * Fahrenheit. + */ + F("imperial"); + + /** + * Human readable unit name. + */ + public final String unitName; + + Unit(String text) { + this.unitName = text; + } + + } + + /** + * Weather Function request. + */ + @JsonInclude(Include.NON_NULL) + @JsonClassDescription("Weather API request") + public record Request(@JsonProperty(required = true, + value = "location") @JsonPropertyDescription("The city and state e.g. San Francisco, CA") String location, + @JsonProperty(required = true, value = "unit") @JsonPropertyDescription("Temperature unit") Unit unit) { + + } + + /** + * Weather Function response. + */ + public record Response(double temp, double feels_like, double temp_min, double temp_max, int pressure, int humidity, + Unit unit) { + + } + +} diff --git a/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/api/tool/PaymentStatusFunctionCallingIT.java b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/api/tool/PaymentStatusFunctionCallingIT.java new file mode 100644 index 00000000000..fb277bfd7df --- /dev/null +++ b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/api/tool/PaymentStatusFunctionCallingIT.java @@ -0,0 +1,167 @@ +/* + * Copyright 2023-2025 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.cohere.api.tool; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +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.cohere.api.CohereApi; +import org.springframework.ai.cohere.api.CohereApi.ChatCompletion; +import org.springframework.ai.cohere.api.CohereApi.ChatCompletionMessage; +import org.springframework.ai.cohere.api.CohereApi.ChatCompletionMessage.Role; +import org.springframework.ai.cohere.api.CohereApi.ChatCompletionMessage.ToolCall; +import org.springframework.ai.cohere.api.CohereApi.ChatCompletionRequest; +import org.springframework.ai.cohere.api.CohereApi.ChatCompletionRequest.ToolChoice; +import org.springframework.ai.cohere.api.CohereApi.FunctionTool; +import org.springframework.ai.cohere.api.CohereApi.FunctionTool.Type; +import org.springframework.http.ResponseEntity; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +import static org.assertj.core.api.Assertions.assertThat; + +@EnabledIfEnvironmentVariable(named = "COHERE_API_KEY", matches = ".+") +public class PaymentStatusFunctionCallingIT { + + // Assuming we have the following data + public static final Map DATA = Map.of("T1001", new StatusDate("Paid", "2021-10-05"), "T1002", + new StatusDate("Unpaid", "2021-10-06"), "T1003", new StatusDate("Paid", "2021-10-07"), "T1004", + new StatusDate("Paid", "2021-10-05"), "T1005", new StatusDate("Pending", "2021-10-08")); + + static Map> functions = Map.of("retrieve_payment_status", + new RetrievePaymentStatus(), "retrieve_payment_date", new RetrievePaymentDate()); + + private final Logger logger = LoggerFactory.getLogger(PaymentStatusFunctionCallingIT.class); + + private static T jsonToObject(String json, Class targetClass) { + try { + return new ObjectMapper().readValue(json, targetClass); + } + catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + + @Test + @SuppressWarnings("null") + public void toolFunctionCall() throws JsonProcessingException { + + var transactionJsonSchema = """ + { + "type": "object", + "properties": { + "transaction_id": { + "type": "string", + "description": "The transaction id" + } + }, + "required": ["transaction_id"] + } + """; + + var paymentStatusTool = new FunctionTool(Type.FUNCTION, new FunctionTool.Function( + "Get payment status of a transaction", "retrieve_payment_status", transactionJsonSchema)); + + var paymentDateTool = new FunctionTool(Type.FUNCTION, new FunctionTool.Function( + "Get payment date of a transaction", "retrieve_payment_date", transactionJsonSchema)); + + List messages = new ArrayList<>( + List.of(new ChatCompletionMessage("What's the status of my transaction with id T1001?", Role.USER))); + + CohereApi cohereApi = CohereApi.builder().apiKey(System.getenv("COHERE_API_KEY")).build(); + + ResponseEntity response = cohereApi.chatCompletionEntity(new ChatCompletionRequest(messages, + CohereApi.ChatModel.COMMAND_A_R7B.getValue(), List.of(paymentStatusTool, paymentDateTool), ToolChoice.REQUIRED)); + + ChatCompletion chatCompletion = response.getBody(); + + ChatCompletionMessage responseMessage = new ChatCompletionMessage(chatCompletion.message().content(), + chatCompletion.message().role(), chatCompletion.message().toolPlan(), chatCompletion.message().toolCalls(), chatCompletion.message().citations(), null); + + assertThat(responseMessage.role()).isEqualTo(Role.ASSISTANT); + assertThat(responseMessage.toolCalls()).isNotNull(); + + // extend conversation with assistant's reply. + messages.add(responseMessage); + + // Send the info for each function call and function response to the model. + for (ToolCall toolCall : responseMessage.toolCalls()) { + + var functionName = toolCall.function().name(); + // Map the function, JSON arguments into a Transaction object. + Transaction transaction = jsonToObject(toolCall.function().arguments(), Transaction.class); + // Call the target function with the transaction object. + var result = functions.get(functionName).apply(transaction); + + // Extend conversation with function response. + // The functionName is used to identify the function response! + messages.add(new ChatCompletionMessage(result.toString(), Role.TOOL, functionName, null, responseMessage.citations(), toolCall.id())); + } + + response = cohereApi + .chatCompletionEntity(new ChatCompletionRequest(messages, CohereApi.ChatModel.COMMAND_A_R7B.getValue())); + + chatCompletion = response.getBody(); + var content = chatCompletion.message().content().get(0).text(); + logger.info("Final response: {}", content); + + assertThat(content).containsIgnoringCase("T1001"); + assertThat(content).containsIgnoringCase("Paid"); + } + + record StatusDate(String status, String date) { + + } + + public record Transaction(@JsonProperty(required = true, value = "transaction_id") String transactionId) { + + } + + public record Status(@JsonProperty(required = true, value = "status") String status) { + + } + + public record Date(@JsonProperty(required = true, value = "date") String date) { + + } + + private static class RetrievePaymentStatus implements Function { + + @Override + public Status apply(Transaction paymentTransaction) { + return new Status(DATA.get(paymentTransaction.transactionId).status); + } + + } + + private static class RetrievePaymentDate implements Function { + + @Override + public Date apply(Transaction paymentTransaction) { + return new Date(DATA.get(paymentTransaction.transactionId).date); + } + + } + +} From 0d2f20f040dae412f22977bf6fa9ad278e3019f1 Mon Sep 17 00:00:00 2001 From: Ricken Bazolo Date: Wed, 19 Nov 2025 21:32:47 +0100 Subject: [PATCH 12/18] added cohere support :: fix function calling streaming Signed-off-by: ricken07 --- .../ai/cohere/api/CohereApi.java | 66 ++-- .../CohereStreamFunctionCallingHelper.java | 249 +++++++++------ .../ai/cohere/chat/CohereChatModel.java | 134 ++++---- .../ai/cohere/chat/CohereChatOptions.java | 23 ++ .../schema/CohereToolCallingManager.java | 98 ++++++ .../ai/cohere/CohereChatClientIT.java | 289 ++++++++++++++++++ .../eval/qa-evaluator-accurate-answer.st | 3 + .../eval/qa-evaluator-fact-based-answer.st | 7 + .../eval/qa-evaluator-not-related-message.st | 4 + .../prompts/eval/user-evaluator-message.st | 6 + .../test/resources/prompts/system-message.st | 3 + 11 files changed, 707 insertions(+), 175 deletions(-) create mode 100644 models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/schema/CohereToolCallingManager.java create mode 100644 models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/CohereChatClientIT.java create mode 100644 models/spring-ai-cohere/src/test/resources/prompts/eval/qa-evaluator-accurate-answer.st create mode 100644 models/spring-ai-cohere/src/test/resources/prompts/eval/qa-evaluator-fact-based-answer.st create mode 100644 models/spring-ai-cohere/src/test/resources/prompts/eval/qa-evaluator-not-related-message.st create mode 100644 models/spring-ai-cohere/src/test/resources/prompts/eval/user-evaluator-message.st create mode 100644 models/spring-ai-cohere/src/test/resources/prompts/system-message.st diff --git a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/api/CohereApi.java b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/api/CohereApi.java index b56d8ce7875..2042e64885c 100644 --- a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/api/CohereApi.java +++ b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/api/CohereApi.java @@ -16,10 +16,7 @@ package org.springframework.ai.cohere.api; -import com.fasterxml.jackson.annotation.JsonIgnore; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import com.fasterxml.jackson.annotation.JsonInclude; -import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.*; import org.springframework.ai.model.ChatModelDescription; import org.springframework.ai.model.ModelOptionsUtils; import org.springframework.ai.observation.conventions.AiProvider; @@ -39,7 +36,6 @@ import java.util.List; import java.util.Map; import java.util.Objects; -import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Consumer; import java.util.function.Predicate; @@ -356,8 +352,9 @@ public enum ToolChoice { * the {@link ChatCompletionFinishReason#TOOL_CALL} role and null otherwise. */ public record ChatCompletionMessage(@JsonProperty("content") Object rawContent, @JsonProperty("role") Role role, - @JsonProperty("tool_plan") String toolPlan, @JsonProperty("tool_calls") List toolCalls, - @JsonProperty("citations") List citations, @JsonProperty("tool_call_id") String toolCallId) { + @JsonProperty("tool_plan") String toolPlan, + @JsonFormat(with = JsonFormat.Feature.ACCEPT_SINGLE_VALUE_AS_ARRAY) @JsonProperty("tool_calls") List toolCalls, + @JsonFormat(with = JsonFormat.Feature.ACCEPT_SINGLE_VALUE_AS_ARRAY) @JsonProperty("citations") List citations, @JsonProperty("tool_call_id") String toolCallId) { public ChatCompletionMessage(Object content, Role role) { this(content, role, null, null, null, null); @@ -371,6 +368,10 @@ public ChatCompletionMessage(Object content, Role role, List toolCalls this(content, role, toolPlan, toolCalls, null, null); } + public ChatCompletionMessage(Object content, Role role, String toolCallId) { + this(content, role, null, null, null, toolCallId); + } + /** * Get message content as String. */ @@ -1178,8 +1179,6 @@ public Flux chatCompletionStream(ChatCompletionRequest chat Assert.notNull(chatRequest, "The request body can not be null."); Assert.isTrue(chatRequest.stream(), "Request must set the stream property to true."); - AtomicBoolean isInsideTool = new AtomicBoolean(false); - return this.webClient.post() .uri("v2/chat") .body(Mono.just(chatRequest), ChatCompletionRequest.class) @@ -1188,31 +1187,38 @@ public Flux chatCompletionStream(ChatCompletionRequest chat .takeUntil(SSE_DONE_PREDICATE) .filter(SSE_DONE_PREDICATE.negate()) .map(content -> ModelOptionsUtils.jsonToObject(content, ChatCompletionChunk.class)) - .map(chunk -> { - if (this.chunkMerger.isStreamingToolFunctionCall(chunk)) { - isInsideTool.set(true); - } - return chunk; - }) - .windowUntil(chunk -> { - if (isInsideTool.get()) { - if (this.chunkMerger.isStreamingToolFunctionCallFinish(chunk)) { - isInsideTool.set(false); - return true; - } - return !isInsideTool.get(); - } - return "message-end".equals(chunk.type()); - }) - .concatMap(window -> - window.reduce( - new ChatCompletionChunk(null, null, null, null), - this.chunkMerger::merge - ) + .groupBy(chunk -> chunk.id() != null ? chunk.id() : "no-id") + .flatMap(group -> + group.reduce( + new ChatCompletionChunk(null, null, null, null), + this.chunkMerger::merge + ) + .filter(chunk -> EventType.MESSAGE_END.value.equals(chunk.type()) || + (chunk.delta() != null && chunk.delta().finishReason() != null)) ) + .map(chunkMerger::sanitizeToolCalls) + .filter(chunkMerger::hasValidToolCallsOnly) .filter(Objects::nonNull); } + public enum EventType { + MESSAGE_END("message-end"), + CONTENT_START("content-start"), + CONTENT_DELTA("content-delta"), + CONTENT_END("content-end"), + TOOL_PLAN_DELTA("tool-plan-delta"), + TOOL_CALL_START("tool-call-start"), + TOOL_CALL_DELTA("tool-call-delta"), + CITATION_START("citation-start"); + public final String value; + EventType(String value) { + this.value = value; + } + public String getValue() { + return this.value; + } + } + public static Builder builder() { return new Builder(); } diff --git a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/api/CohereStreamFunctionCallingHelper.java b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/api/CohereStreamFunctionCallingHelper.java index 00661a0b352..08cecf36089 100644 --- a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/api/CohereStreamFunctionCallingHelper.java +++ b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/api/CohereStreamFunctionCallingHelper.java @@ -21,12 +21,14 @@ import org.springframework.ai.cohere.api.CohereApi.ChatCompletionMessage.ChatCompletionFunction; import org.springframework.ai.cohere.api.CohereApi.ChatCompletionMessage.Role; import org.springframework.ai.cohere.api.CohereApi.ChatCompletionMessage.ToolCall; -import org.springframework.util.CollectionUtils; +import org.springframework.util.ObjectUtils; import java.util.ArrayList; import java.util.List; import java.util.Map; +import static org.springframework.ai.cohere.api.CohereApi.EventType.*; + /** * @author Ricken Bazolo */ @@ -62,26 +64,35 @@ public ChatCompletionChunk merge(ChatCompletionChunk previous, ChatCompletionChu String currentText = currentMessage != null ? extractTextFromRawContent(currentMessage.rawContent()) : ""; - String mergedText = previousText + currentText; + String currentType = current.type(); + String mergedText; + if (CONTENT_START.getValue().equals(currentType)) { + mergedText = currentText; + } else if (CONTENT_END.getValue().equals(currentType)) { + mergedText = previousText; + } else { + mergedText = previousText + currentText; + } + + String previousPlan = previousMessage != null ? previousMessage.toolPlan() : null; + String currentPlan = currentMessage != null ? currentMessage.toolPlan() : null; + + String mergedToolPlan = previousPlan; - String toolPlan = previousMessage != null && previousMessage.toolPlan() != null - ? previousMessage.toolPlan() - : (currentMessage != null ? currentMessage.toolPlan() : null); + if (TOOL_PLAN_DELTA.getValue().equals(current.type())) { + mergedToolPlan = mergeToolPlan(previousPlan, currentPlan); + } - List toolCalls = previousMessage != null && previousMessage.toolCalls() != null && !previousMessage.toolCalls().isEmpty() - ? previousMessage.toolCalls() - : (currentMessage != null ? currentMessage.toolCalls() : null); + List mergedToolCalls = mergeToolCalls(previous, current); - List citations = previousMessage != null && previousMessage.citations() != null && !previousMessage.citations().isEmpty() - ? previousMessage.citations() - : (currentMessage != null ? currentMessage.citations() : null); + List citations = mergeCitations(previous, current); ChatCompletionMessage mergedMessage = new ChatCompletionMessage( mergedText, role, - toolPlan, - toolCalls, + mergedToolPlan, + mergedToolCalls, citations, null ); @@ -107,6 +118,62 @@ public ChatCompletionChunk merge(ChatCompletionChunk previous, ChatCompletionChu return new ChatCompletionChunk(id, type, index, mergedDelta); } + public ChatCompletionChunk sanitizeToolCalls(ChatCompletionChunk chunk) { + if (chunk == null || chunk.delta() == null || chunk.delta().message() == null) + return chunk; + + ChatCompletionMessage msg = chunk.delta().message(); + List toolCalls = msg.toolCalls(); + + if (toolCalls == null || toolCalls.isEmpty()) return chunk; + + List cleaned = + toolCalls.stream().filter(this::isValidToolCall).toList(); + + ChatCompletionChunk.ChunkDelta oldDelta = chunk.delta(); + + ChatCompletionMessage cleanedMsg = + new ChatCompletionMessage( + msg.rawContent(), + msg.role(), + msg.toolPlan(), + cleaned.isEmpty() ? null : cleaned, + msg.citations(), + null + ); + + ChatCompletionChunk.ChunkDelta newDelta = + new ChatCompletionChunk.ChunkDelta(cleanedMsg, oldDelta.finishReason(), oldDelta.usage()); + + return new ChatCompletionChunk(chunk.id(), chunk.type(), chunk.index(), newDelta); + } + + public boolean hasValidToolCallsOnly(ChatCompletionChunk c) { + if (c == null || c.delta() == null || c.delta().message() == null) + return false; + + ChatCompletionMessage message = c.delta().message(); + List calls = message.toolCalls(); + + boolean hasValidToolCalls = calls != null && calls.stream().anyMatch(this::isValidToolCall); + + boolean hasTextContent = message.rawContent() != null && !extractTextFromRawContent(message.rawContent()).isEmpty(); + + boolean hasCitations = message.citations() != null && !message.citations().isEmpty(); + + return hasValidToolCalls || hasTextContent || hasCitations; + } + + private boolean isValidToolCall(ToolCall toolCall) { + if (toolCall == null || toolCall.function() == null) { + return false; + } + ChatCompletionFunction chatCompletionFunction = toolCall.function(); + String functionName = chatCompletionFunction.name(); + String functionArguments = chatCompletionFunction.arguments(); + return !ObjectUtils.isEmpty(functionName) && !ObjectUtils.isEmpty(functionArguments); + } + private String extractTextFromRawContent(Object rawContent) { if (rawContent == null) { return ""; @@ -131,99 +198,109 @@ private String extractTextFromRawContent(Object rawContent) { return rawContent.toString(); } + private List mergeToolCalls(ChatCompletionChunk previous, + ChatCompletionChunk current) { + ChatCompletionMessage previousMessage = previous != null && previous.delta() != null + ? previous.delta().message() : null; + ChatCompletionMessage currentMessage = current.delta() != null + ? current.delta().message() : null; - private ChatCompletionMessage merge(ChatCompletionMessage previous, ChatCompletionMessage current) { - String content = (current.content() != null ? current.content() - : (previous.content() != null) ? previous.content() : ""); - Role role = (current.role() != null ? current.role() : previous.role()); - role = (role != null ? role : Role.ASSISTANT); - String toolPlan = (current.toolPlan() != null ? current.toolPlan() : previous.toolPlan()); + List merged = ensureToolCallList(previousMessage != null ? previousMessage.toolCalls() : null); - List toolCalls = new ArrayList<>(); - ToolCall lastPreviousTooCall = null; - if (previous.toolCalls() != null) { - lastPreviousTooCall = previous.toolCalls().get(previous.toolCalls().size() - 1); - if (previous.toolCalls().size() > 1) { - toolCalls.addAll(previous.toolCalls().subList(0, previous.toolCalls().size() - 1)); - } + String type = current.type(); + Integer index = current.index(); + + if (index == null) { + return merged; } - if (current.toolCalls() != null) { - if (current.toolCalls().size() > 1) { - throw new IllegalStateException("Currently only one tool call is supported per message!"); - } - var currentToolCall = current.toolCalls().iterator().next(); - if (currentToolCall.id() != null) { - if (lastPreviousTooCall != null) { - toolCalls.add(lastPreviousTooCall); - } - toolCalls.add(currentToolCall); - } - else { - toolCalls.add(merge(lastPreviousTooCall, currentToolCall)); - } + + ToolCall existing = ensureToolCallAtIndex(merged, index); + ChatCompletionFunction existingFunction = existing.function() != null + ? existing.function() + : new ChatCompletionFunction(null, ""); + + String id = existing.id(); + String callType = existing.type(); + String functionName = existingFunction.name(); + String args = existingFunction.arguments() != null ? existingFunction.arguments() : ""; + + if (TOOL_CALL_START.getValue().equals(type) && currentMessage != null && currentMessage.toolCalls() != null + && !currentMessage.toolCalls().isEmpty()) { + + ToolCall start = currentMessage.toolCalls().get(0); + ChatCompletionFunction startFunction = start.function() != null + ? start.function() + : new ChatCompletionFunction(null, ""); + + id = start.id() != null ? start.id() : id; + callType = start.type() != null ? start.type() : callType; + functionName = startFunction.name() != null ? startFunction.name() : functionName; + } - else { - if (lastPreviousTooCall != null) { - toolCalls.add(lastPreviousTooCall); + + if (TOOL_CALL_DELTA.getValue().equals(type) && currentMessage != null && currentMessage.toolCalls() != null + && !currentMessage.toolCalls().isEmpty()) { + + ToolCall deltaCall = currentMessage.toolCalls().get(0); + ChatCompletionFunction deltaFunction = deltaCall.function(); + if (deltaFunction != null && deltaFunction.arguments() != null) { + args = (args == null ? "" : args) + deltaFunction.arguments(); } } - return new ChatCompletionMessage(content, role, toolCalls, toolPlan); + + // tool-call-end + ChatCompletionFunction mergedFn = new ChatCompletionFunction(functionName, args); + ToolCall mergedCall = new ToolCall(id, callType, mergedFn, index); + merged.set(index, mergedCall); + + return merged; } - private ToolCall merge(ToolCall previous, ToolCall current) { + private String mergeToolPlan(String previous, String currentFragment) { + if (currentFragment == null || currentFragment.isEmpty()) { + return previous; + } if (previous == null) { - return current; + return currentFragment; } - String id = (current.id() != null ? current.id() : previous.id()); - String type = (current.type() != null ? current.type() : previous.type()); - ChatCompletionFunction function = merge(previous.function(), current.function()); - Integer index = (current.index() != null ? current.index() : previous.index()); - return new ToolCall(id, type, function, index); + return previous + currentFragment; } - private ChatCompletionFunction merge(ChatCompletionFunction previous, ChatCompletionFunction current) { - if (previous == null) { - return current; - } - String name = (current.name() != null ? current.name() : previous.name()); - StringBuilder arguments = new StringBuilder(); - if (previous.arguments() != null) { - arguments.append(previous.arguments()); - } - if (current.arguments() != null) { - arguments.append(current.arguments()); + private List mergeCitations( + ChatCompletionChunk previous, ChatCompletionChunk current) { + + ChatCompletionMessage previousMessage = previous != null && previous.delta() != null + ? previous.delta().message() : null; + ChatCompletionMessage currentMessage = current != null && current.delta() != null + ? current.delta().message() : null; + + List merged = new ArrayList<>(); + + if (previousMessage != null && previousMessage.citations() != null) { + merged.addAll(previousMessage.citations()); } - return new ChatCompletionFunction(name, arguments.toString()); - } - /** - * @param chatCompletion the ChatCompletionChunk to check - * @return true if the ChatCompletionChunk is a streaming tool function call. - */ - public boolean isStreamingToolFunctionCall(ChatCompletionChunk chatCompletion) { - var delta = chatCompletion.delta(); - if (delta == null) { - return false; + if (current != null && CITATION_START.getValue().equals(current.type()) && currentMessage != null && currentMessage.citations() != null) { + merged.addAll(currentMessage.citations()); } - return !CollectionUtils.isEmpty(delta.message().toolCalls()); + return merged.isEmpty() ? null : merged; } - /** - * @param chatCompletion the ChatCompletionChunk to check - * @return true if the ChatCompletionChunk is a streaming tool function call and it is - * the last one. - */ - public boolean isStreamingToolFunctionCallFinish(ChatCompletionChunk chatCompletion) { - - var delta = chatCompletion.delta(); + private List ensureToolCallList(List toolCalls) { + return (toolCalls != null) ? new ArrayList<>(toolCalls) : new ArrayList<>(); + } - if (delta == null) { - return false; + private ToolCall ensureToolCallAtIndex(List toolCalls, int index) { + while (toolCalls.size() <= index) { + toolCalls.add(new ToolCall(null, null, new ChatCompletionFunction("", ""), index)); } - - return delta.finishReason() == CohereApi.ChatCompletionFinishReason.TOOL_CALLS; + ToolCall call = toolCalls.get(index); + if (call == null) { + call = new ToolCall(null, null, new ChatCompletionFunction("", ""), index); + toolCalls.set(index, call); + } + return call; } } -// --- diff --git a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/chat/CohereChatModel.java b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/chat/CohereChatModel.java index f7a86c9616b..98070bdd153 100644 --- a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/chat/CohereChatModel.java +++ b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/chat/CohereChatModel.java @@ -152,25 +152,13 @@ public static ChatResponseMetadata from(CohereApi.ChatCompletion result, Usage u } private static DefaultUsage getDefaultUsage(CohereApi.Usage usage) { - return new DefaultUsage(null, null, null, usage); + return new DefaultUsage(usage.tokens().inputTokens(), usage.tokens().outputTokens(), null, usage); } @Override public ChatResponse call(Prompt prompt) { - Prompt requestPrompt = this.buildRequestPrompt(prompt); - ChatModelObservationContext observationContext = ChatModelObservationContext.builder() - .prompt(requestPrompt) - .provider(CohereApi.PROVIDER_NAME) - .build(); - - return ChatModelObservationDocumentation.CHAT_MODEL_OPERATION - .observation(this.observationConvention, DEFAULT_OBSERVATION_CONVENTION, () -> observationContext, - this.observationRegistry) - .observe(() -> { - ChatResponse chatResponse = doChatRequest(requestPrompt); - observationContext.setResponse(chatResponse); - return chatResponse; - }); + Prompt requestPrompt = buildRequestPrompt(prompt); + return this.internalCall(requestPrompt, null); } @Override @@ -212,19 +200,17 @@ public Flux internalStream(Prompt prompt, ChatResponse previousCha ChatCompletionMessage.Provider message = completion.message(); // Store the role for this completion ID - if (message.role() != null) { + if (message.role() != null && id != null) { roleMap.putIfAbsent(id, message.role().name()); } - // @formatter:off List generations = message.content().stream().map(content -> { Map metadata = Map.of( "id", completion.id() != null ? completion.id() : "", - "role", roleMap.getOrDefault(id, ""), + "role", completion.id() != null ? roleMap.getOrDefault(id, "") : "", "finishReason", completion.finishReason() != null ? completion.finishReason().name() : ""); return buildGeneration(content, completion, metadata); }).toList(); - // @formatter:on if (completion.usage() != null) { DefaultUsage usage = getDefaultUsage(completion.usage()); @@ -261,7 +247,8 @@ public Flux internalStream(Prompt prompt, ChatResponse previousCha } else { // Send the tool execution result back to the model. - return this.internalStream(new Prompt(toolExecutionResult.conversationHistory(), prompt.getOptions()), + var chatOptions = CohereChatOptions.fromOptions(prompt.getOptions().copy()); + return this.internalStream(new Prompt(toolExecutionResult.conversationHistory(), chatOptions), response); } }).subscribeOn(Schedulers.boundedElastic()); @@ -336,6 +323,13 @@ else if (item instanceof Map map) { return messageContents; } + if (rawContent instanceof Map map) { + String type = (String) map.get("type"); + String text = (String) map.get("text"); + Object value = map.get("value"); + return List.of(new ChatCompletionMessage.MessageContent(type != null ? type : "text", text, value)); + } + return List.of(); } @@ -382,58 +376,80 @@ Prompt buildRequestPrompt(Prompt prompt) { return new Prompt(prompt.getInstructions(), requestOptions); } - private ChatResponse doChatRequest(Prompt prompt) { - ChatResponse response = this.internalCall(prompt, null); + private ChatResponse internalCall(Prompt prompt, ChatResponse previousChatResponse) { + ChatCompletionRequest request = createRequest(prompt, false); + + ChatModelObservationContext observationContext = ChatModelObservationContext.builder() + .prompt(prompt) + .provider(CohereApi.PROVIDER_NAME) + .build(); + + ChatResponse response = ChatModelObservationDocumentation.CHAT_MODEL_OPERATION + .observation(this.observationConvention, DEFAULT_OBSERVATION_CONVENTION, () -> observationContext, + this.observationRegistry) + .observe(() -> { + + ResponseEntity completionEntity = this.retryTemplate + .execute(ctx -> this.cohereApi.chatCompletionEntity(request)); + + ChatCompletion chatCompletion = completionEntity.getBody(); + + if (chatCompletion == null) { + logger.warn("No chat completion returned for prompt: {}", prompt); + return new ChatResponse(List.of()); + } + + final Map metadata = Map.of("id", chatCompletion.id() != null ? chatCompletion.id() : "", + "role", chatCompletion.message().role() != null ? chatCompletion.message().role().name() : "", + "finishReason", + chatCompletion.finishReason() != null ? chatCompletion.finishReason().name() : ""); + + List generations = new ArrayList<>(); + + if (chatCompletion.finishReason() == null) { // Just for secure + logger.warn("No chat completion finishReason returned for prompt: {}", prompt); + return new ChatResponse(List.of()); + } + + if (chatCompletion.finishReason().equals(CohereApi.ChatCompletionFinishReason.TOOL_CALL)) { + var generation = buildGeneration(null, chatCompletion, metadata); + generations.add(generation); + } else { + generations = chatCompletion.message().content().stream().map(content -> buildGeneration(content, chatCompletion, metadata)).toList(); + } + + DefaultUsage usage = getDefaultUsage(completionEntity.getBody().usage()); + Usage cumulativeUsage = UsageCalculator.getCumulativeUsage(usage, previousChatResponse); + + ChatResponse chatResponse = new ChatResponse(generations, + from(completionEntity.getBody(), cumulativeUsage)); + + observationContext.setResponse(chatResponse); + + return chatResponse; + }); if (this.toolExecutionEligibilityPredicate.isToolExecutionRequired(prompt.getOptions(), response)) { var toolExecutionResult = this.toolCallingManager.executeToolCalls(prompt, response); if (toolExecutionResult.returnDirect()) { // Return tool execution result directly to the client. return ChatResponse.builder() - .from(response) - .generations(ToolExecutionResult.buildGenerations(toolExecutionResult)) - .build(); + .from(response) + .generations(ToolExecutionResult.buildGenerations(toolExecutionResult)) + .build(); } else { + // remove tools actions before + ChatOptions chatOptions = CohereChatOptions.fromOptions2(prompt.getOptions().copy()); // Send the tool execution result back to the model. - return this.internalCall(new Prompt(toolExecutionResult.conversationHistory(), prompt.getOptions()), - response); + return this.internalCall(new Prompt(toolExecutionResult.conversationHistory(), chatOptions), + null); } } return response; } - private ChatResponse internalCall(Prompt prompt, ChatResponse previousChatResponse) { - ChatCompletionRequest request = createRequest(prompt, false); - - return this.retryTemplate.execute(ctx -> { - - ResponseEntity completionEntity = this.cohereApi.chatCompletionEntity(request); - - var chatCompletion = completionEntity.getBody(); - if (chatCompletion == null) { - logger.warn("No chat completion returned for prompt: {}", prompt); - return new ChatResponse(List.of()); - } - - List generations = chatCompletion.message().content().stream().map(content -> { - Map metadata = Map.of("id", chatCompletion.id() != null ? chatCompletion.id() : "", - "role", chatCompletion.message().role() != null ? chatCompletion.message().role().name() : "", - "finishReason", - chatCompletion.finishReason() != null ? chatCompletion.finishReason().name() : ""); - return buildGeneration(content, chatCompletion, metadata); - }).toList(); - - DefaultUsage usage = getDefaultUsage(completionEntity.getBody().usage()); - Usage cumulativeUsage = UsageCalculator.getCumulativeUsage(usage, previousChatResponse); - ChatResponse chatResponse = new ChatResponse(generations, - from(completionEntity.getBody(), cumulativeUsage)); - - return chatResponse; - }); - } - private Generation buildGeneration(ChatCompletionMessage.MessageContent content, ChatCompletion completion, Map metadata) { List toolCalls = completion.message().toolCalls() == null ? List.of() @@ -445,7 +461,7 @@ private Generation buildGeneration(ChatCompletionMessage.MessageContent content, .toList(); var assistantMessage = AssistantMessage.builder() - .content(content.text()) + .content(content != null ? content.text() : "") .toolCalls(toolCalls) .properties(metadata) .build(); @@ -494,7 +510,7 @@ else if (message instanceof ToolResponseMessage toolResponseMessage) { return toolResponseMessage.getResponses() .stream() - .map(toolResponse -> new ChatCompletionMessage(toolResponse.responseData(), Role.TOOL)) + .map(toolResponse -> new ChatCompletionMessage(toolResponse.responseData(), Role.TOOL, toolResponse.name(), null, null, toolResponse.id())) .toList(); } else { diff --git a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/chat/CohereChatOptions.java b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/chat/CohereChatOptions.java index 1bd094bf3ec..9ad5b2067ca 100644 --- a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/chat/CohereChatOptions.java +++ b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/chat/CohereChatOptions.java @@ -401,6 +401,29 @@ public static CohereChatOptions fromOptions(CohereChatOptions fromOptions) { .build(); } + public static CohereChatOptions fromOptions2(CohereChatOptions fromOptions) { + return CohereChatOptions.builder() + .model(fromOptions.getModel()) + .temperature(fromOptions.getTemperature()) + .maxTokens(fromOptions.getMaxTokens()) + .topP(fromOptions.getTopP()) + .frequencyPenalty(fromOptions.getFrequencyPenalty()) + .presencePenalty(fromOptions.getPresencePenalty()) + .topK(fromOptions.getTopK()) + .tools(null) + .responseFormat(fromOptions.getResponseFormat()) + .safetyMode(fromOptions.getSafetyMode()) + .stop(fromOptions.getStopSequences()) + .seed(fromOptions.getSeed()) + .logprobs(fromOptions.getLogprobs()) + .toolChoice(null) + .strictTools(null) + .toolCallbacks() + .toolNames() + .internalToolExecutionEnabled(null) + .build(); + } + public static class Builder { private final CohereChatOptions options = new CohereChatOptions(); diff --git a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/schema/CohereToolCallingManager.java b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/schema/CohereToolCallingManager.java new file mode 100644 index 00000000000..1c837495852 --- /dev/null +++ b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/schema/CohereToolCallingManager.java @@ -0,0 +1,98 @@ +/* + * Copyright 2025-2025 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.cohere.schema; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.model.tool.ToolCallingChatOptions; +import org.springframework.ai.model.tool.ToolCallingManager; +import org.springframework.ai.model.tool.ToolExecutionResult; +import org.springframework.ai.tool.definition.DefaultToolDefinition; +import org.springframework.ai.tool.definition.ToolDefinition; +import org.springframework.ai.util.json.schema.JsonSchemaGenerator; +import org.springframework.util.Assert; + +import java.util.List; + +/** + * Implementation of {@link ToolCallingManager} specifically designed for Vertex AI + * Gemini. This manager adapts tool definitions to be compatible with Vertex AI's OpenAPI + * schema format by converting JSON schemas and ensuring proper type value upper-casing. + * + *

+ * It delegates the actual tool execution to another {@link ToolCallingManager} while + * handling the necessary schema conversions for Vertex AI compatibility. + * + * @author Christian Tzolov + * @since 1.0.0 + */ +public class CohereToolCallingManager implements ToolCallingManager { + + /** + * The underlying tool calling manager that handles actual tool execution. + */ + private final ToolCallingManager delegateToolCallingManager; + + /** + * Creates a new instance of VertexToolCallingManager. + * @param delegateToolCallingManager the underlying tool calling manager that handles + * actual tool execution + */ + public CohereToolCallingManager(ToolCallingManager delegateToolCallingManager) { + Assert.notNull(delegateToolCallingManager, "Delegate tool calling manager must not be null"); + this.delegateToolCallingManager = delegateToolCallingManager; + } + + /** + * Resolves tool definitions and converts their input schemas to be compatible with + * Vertex AI's OpenAPI format. This includes converting JSON schemas to OpenAPI format + * and ensuring proper type value casing. + * @param chatOptions the options containing tool preferences and configurations + * @return a list of tool definitions with Vertex AI compatible schemas + */ + @Override + public List resolveToolDefinitions(ToolCallingChatOptions chatOptions) { + + List toolDefinitions = this.delegateToolCallingManager.resolveToolDefinitions(chatOptions); + + /*return toolDefinitions.stream().map(td -> { + ObjectNode jsonSchema = JsonSchemaConverter.fromJson(td.inputSchema()); + ObjectNode openApiSchema = JsonSchemaConverter.convertToOpenApiSchema(jsonSchema); + JsonSchemaGenerator.convertTypeValuesToUpperCase(openApiSchema); + + return DefaultToolDefinition.builder() + .name(td.name()) + .description(td.description()) + .inputSchema(openApiSchema.toPrettyString()) + .build(); + }).toList();*/ + return List.of(); + } + + /** + * Executes tool calls by delegating to the underlying tool calling manager. + * @param prompt the original prompt that triggered the tool calls + * @param chatResponse the chat response containing the tool calls to execute + * @return the result of executing the tool calls + */ + @Override + public ToolExecutionResult executeToolCalls(Prompt prompt, ChatResponse chatResponse) { + return this.delegateToolCallingManager.executeToolCalls(prompt, chatResponse); + } + +} diff --git a/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/CohereChatClientIT.java b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/CohereChatClientIT.java new file mode 100644 index 00000000000..b74f66c01ff --- /dev/null +++ b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/CohereChatClientIT.java @@ -0,0 +1,289 @@ +/* + * 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.cohere; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.cohere.api.tool.MockWeatherService; +import org.springframework.ai.cohere.chat.CohereChatOptions; +import org.springframework.ai.cohere.testutils.AbstractIT; +import org.springframework.ai.converter.BeanOutputConverter; +import org.springframework.ai.converter.ListOutputConverter; +import org.springframework.ai.cohere.api.CohereApi; +import org.springframework.ai.cohere.api.CohereApi.ChatCompletionRequest.ToolChoice; +import org.springframework.ai.test.CurlyBracketEscaper; +import org.springframework.ai.tool.function.FunctionToolCallback; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.core.convert.support.DefaultConversionService; +import org.springframework.core.io.Resource; +import reactor.core.publisher.Flux; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(classes = CohereTestConfiguration.class) +@EnabledIfEnvironmentVariable(named = "COHERE_API_KEY", matches = ".+") +class CohereChatClientIT extends AbstractIT { + + private static final Logger logger = LoggerFactory.getLogger(CohereChatClientIT.class); + + @Value("classpath:/prompts/system-message.st") + private Resource systemTextResource; + + @Test + void call() { + // @formatter:off + ChatResponse response = ChatClient.create(this.chatModel).prompt() + .system(s -> s.text(this.systemTextResource) + .param("name", "Bob") + .param("voice", "pirate")) + .user("Tell me about 3 famous pirates from the Golden Age of Piracy and what they did") + .call() + .chatResponse(); + // @formatter:on + + logger.info("{}", response); + assertThat(response.getResults()).hasSize(1); + assertThat(response.getResults().get(0).getOutput().getText()).contains("Blackbeard"); + } + + @Test + void testMessageHistory() { + + // @formatter:off + ChatResponse response = ChatClient.create(this.chatModel).prompt() + .system(s -> s.text(this.systemTextResource) + .param("name", "Bob") + .param("voice", "pirate")) + .user("Tell me about 3 famous pirates from the Golden Age of Piracy and what they did") + .call() + .chatResponse(); + // @formatter:on + assertThat(response.getResult().getOutput().getText()).containsAnyOf("Blackbeard"); + + // @formatter:off + response = ChatClient.create(this.chatModel).prompt() + .messages(List.of(new UserMessage("Dummy"), response.getResult().getOutput())) + .user("Repeat the last assistant message.") + .call() + .chatResponse(); + // @formatter:on + + logger.info("" + response); + assertThat(response.getResult().getOutput().getText().toLowerCase()).containsAnyOf("blackbeard", + "bartholomew roberts"); + } + + @Test + void listOutputConverterString() { + // @formatter:off + List collection = ChatClient.create(this.chatModel).prompt() + .user(u -> u.text("List five {subject}") + .param("subject", "ice cream flavors")) + .call() + .entity(new ParameterizedTypeReference<>() { }); + // @formatter:on + + logger.info(collection.toString()); + assertThat(collection).hasSize(5); + } + + @Test + void listOutputConverterBean() { + + // @formatter:off + List actorsFilms = ChatClient.create(this.chatModel).prompt() + .user("Generate the filmography of 5 movies for Tom Hanks and Bill Murray.") + .call() + .entity(new ParameterizedTypeReference<>() { + }); + // @formatter:on + + logger.info("" + actorsFilms); + assertThat(actorsFilms).hasSize(2); + } + + @Test + void customOutputConverter() { + + var toStringListConverter = new ListOutputConverter(new DefaultConversionService()); + + // @formatter:off + List flavors = ChatClient.create(this.chatModel).prompt() + .user(u -> u.text("List 10 {subject}") + .param("subject", "ice cream flavors")) + .call() + .entity(toStringListConverter); + // @formatter:on + + logger.info("ice cream flavors{}", flavors); + assertThat(flavors).hasSize(10); + assertThat(flavors).containsAnyOf("Vanilla", "vanilla"); + } + + @Test + void mapOutputConverter() { + // @formatter:off + Map result = ChatClient.create(this.chatModel).prompt() + .user(u -> u.text("Provide me a List of {subject}") + .param("subject", "an array of numbers from 1 to 9 under they key name 'numbers'")) + .call() + .entity(new ParameterizedTypeReference<>() { + }); + // @formatter:on + + assertThat(result.get("numbers")).isEqualTo(Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9)); + } + + @Test + void beanOutputConverter() { + + // @formatter:off + ActorsFilms actorsFilms = ChatClient.create(this.chatModel).prompt() + .user("Generate the filmography for a random actor.") + .call() + .entity(ActorsFilms.class); + // @formatter:on + + logger.info("{}", actorsFilms); + assertThat(actorsFilms.actor()).isNotBlank(); + } + + @Test + void beanOutputConverterRecords() { + + // @formatter:off + ActorsFilms actorsFilms = ChatClient.create(this.chatModel).prompt() + .user("Generate the filmography of 5 movies for Tom Hanks.") + .call() + .entity(ActorsFilms.class); + // @formatter:on + + logger.info("{}", actorsFilms); + assertThat(actorsFilms.actor()).isEqualTo("Tom Hanks"); + assertThat(actorsFilms.movies()).hasSize(5); + } + + @Test + void beanStreamOutputConverterRecords() { + + BeanOutputConverter outputConverter = new BeanOutputConverter<>(ActorsFilms.class); + + // @formatter:off + Flux chatResponse = ChatClient.create(this.chatModel) + .prompt() + .advisors(new SimpleLoggerAdvisor()) + .user(u -> u + .text("Generate the filmography of 5 movies for Tom Hanks. " + System.lineSeparator() + + "{format}") + .param("format", CurlyBracketEscaper.escapeCurlyBrackets(outputConverter.getFormat()))) + .stream() + .content(); + + String generationTextFromStream = chatResponse.collectList() + .block() + .stream() + .collect(Collectors.joining()); + // @formatter:on + + ActorsFilms actorsFilms = outputConverter.convert(generationTextFromStream); + + logger.info("{}", actorsFilms); + assertThat(actorsFilms.actor()).isEqualTo("Tom Hanks"); + assertThat(actorsFilms.movies()).hasSize(5); + } + + @Test + void functionCallTest() { + + // @formatter:off + String response = ChatClient.create(this.chatModel).prompt() + .options(CohereChatOptions.builder().model(CohereApi.ChatModel.COMMAND_A_R7B).toolChoice(ToolChoice.REQUIRED).build()) + .user(u -> u.text("What's the weather like in San Francisco, Tokyo, and Paris? Use parallel function calling if required. Response should be in Celsius.")) + .toolCallbacks(FunctionToolCallback.builder("getCurrentWeather", new MockWeatherService()) + .description("Get the weather in location") + .inputType(MockWeatherService.Request.class) + .build()) + .call() + .content(); + // @formatter:on + + logger.info("Response: {}", response); + + assertThat(response).containsAnyOf("30.0", "30"); + assertThat(response).containsAnyOf("10.0", "10"); + assertThat(response).containsAnyOf("15.0", "15"); + } + + @Test + void streamFunctionCallTest() { + + // @formatter:off + Flux response = ChatClient.create(this.chatModel).prompt() + .options(CohereChatOptions.builder().model(CohereApi.ChatModel.COMMAND_A_R7B).build()) + .user("What's the weather like in San Francisco, Tokyo, and Paris? Use parallel function calling if required. Response should be in Celsius.") + .toolCallbacks(FunctionToolCallback.builder("getCurrentWeather", new MockWeatherService()) + .description("Get the weather in location") + .inputType(MockWeatherService.Request.class) + .build()) + .stream() + .content(); + // @formatter:on + + String content = response.collectList().block().stream().collect(Collectors.joining()); + logger.info("Response: {}", content); + + assertThat(content).containsAnyOf("30.0", "30"); + assertThat(content).containsAnyOf("10.0", "10"); + assertThat(content).containsAnyOf("15.0", "15"); + } + + @Test + void validateCallResponseMetadata() { + String model = CohereApi.ChatModel.COMMAND_A_R7B.getName(); + // @formatter:off + ChatResponse response = ChatClient.create(this.chatModel).prompt() + .options(CohereChatOptions.builder().model(model).build()) + .user("Tell me about 3 famous pirates from the Golden Age of Piracy and what they did") + .call() + .chatResponse(); + // @formatter:on + + logger.info(response.toString()); + assertThat(response.getMetadata().getId()).isNotEmpty(); + assertThat(response.getMetadata().getUsage().getPromptTokens()).isPositive(); + assertThat(response.getMetadata().getUsage().getCompletionTokens()).isPositive(); + assertThat(response.getMetadata().getUsage().getTotalTokens()).isPositive(); + } + + record ActorsFilms(String actor, List movies) { + + } + +} diff --git a/models/spring-ai-cohere/src/test/resources/prompts/eval/qa-evaluator-accurate-answer.st b/models/spring-ai-cohere/src/test/resources/prompts/eval/qa-evaluator-accurate-answer.st new file mode 100644 index 00000000000..56270359545 --- /dev/null +++ b/models/spring-ai-cohere/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-cohere/src/test/resources/prompts/eval/qa-evaluator-fact-based-answer.st b/models/spring-ai-cohere/src/test/resources/prompts/eval/qa-evaluator-fact-based-answer.st new file mode 100644 index 00000000000..22fc3e88d14 --- /dev/null +++ b/models/spring-ai-cohere/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-cohere/src/test/resources/prompts/eval/qa-evaluator-not-related-message.st b/models/spring-ai-cohere/src/test/resources/prompts/eval/qa-evaluator-not-related-message.st new file mode 100644 index 00000000000..7c33e675e02 --- /dev/null +++ b/models/spring-ai-cohere/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-cohere/src/test/resources/prompts/eval/user-evaluator-message.st b/models/spring-ai-cohere/src/test/resources/prompts/eval/user-evaluator-message.st new file mode 100644 index 00000000000..b3fa3e902d2 --- /dev/null +++ b/models/spring-ai-cohere/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-cohere/src/test/resources/prompts/system-message.st b/models/spring-ai-cohere/src/test/resources/prompts/system-message.st new file mode 100644 index 00000000000..579febd8d9b --- /dev/null +++ b/models/spring-ai-cohere/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 From cdbf0b6e9b19035891f519101230fcd8cf08ad05 Mon Sep 17 00:00:00 2001 From: Ricken Bazolo Date: Thu, 20 Nov 2025 08:09:13 +0100 Subject: [PATCH 13/18] added cohere support :: more test Signed-off-by: ricken07 --- .../CohereChatAutoConfiguration.java | 12 +- .../CohereEmbeddingAutoConfiguration.java | 20 +- .../CohereAutoConfigurationIT.java | 29 +- .../CohereModelConfigurationTests.java | 30 +- .../autoconfigure/CoherePropertiesTests.java | 87 ++--- .../ai/cohere/api/CohereApi.java | 173 ++++----- .../CohereStreamFunctionCallingHelper.java | 91 ++--- .../ai/cohere/chat/CohereChatModel.java | 318 +++++++++-------- .../ai/cohere/chat/CohereChatOptions.java | 96 +++-- .../embedding/CohereEmbeddingModel.java | 25 +- .../embedding/CohereEmbeddingOptions.java | 7 +- .../schema/CohereToolCallingManager.java | 21 +- .../ai/cohere/CohereRetryTests.java | 214 +++++++++++ .../ai/cohere/CohereTestConfiguration.java | 6 + .../ai/cohere/api/CohereApiIT.java | 4 +- .../api/tool/CohereApiToolFunctionCallIT.java | 16 +- .../cohere/api/tool/MockWeatherService.java | 1 - .../tool/PaymentStatusFunctionCallingIT.java | 11 +- .../cohere/{ => chat}/CohereChatClientIT.java | 4 +- .../CohereChatCompletionRequestTests.java | 309 ++++++++++++++++ .../ai/cohere/chat/CohereChatModelIT.java | 336 ++++++++++++++++++ .../chat/CohereChatModelObservationIT.java | 184 ++++++++++ .../cohere/chat/CohereChatOptionsTests.java | 248 +++++++++++++ .../cohere/embedding/CohereEmbeddingIT.java | 89 +++++ .../CohereEmbeddingModelObservationIT.java | 117 ++++++ .../embedding/CohereEmbeddingModelTests.java | 154 ++++++++ 26 files changed, 2162 insertions(+), 440 deletions(-) create mode 100644 models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/CohereRetryTests.java rename models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/{ => chat}/CohereChatClientIT.java (98%) create mode 100644 models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/chat/CohereChatCompletionRequestTests.java create mode 100644 models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/chat/CohereChatModelIT.java create mode 100644 models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/chat/CohereChatModelObservationIT.java create mode 100644 models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/chat/CohereChatOptionsTests.java create mode 100644 models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/embedding/CohereEmbeddingIT.java create mode 100644 models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/embedding/CohereEmbeddingModelObservationIT.java create mode 100644 models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/embedding/CohereEmbeddingModelTests.java diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/java/org/springframework/ai/cohere/autoconfigure/CohereChatAutoConfiguration.java b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/java/org/springframework/ai/cohere/autoconfigure/CohereChatAutoConfiguration.java index 0bda25e6ffe..255123b1807 100644 --- a/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/java/org/springframework/ai/cohere/autoconfigure/CohereChatAutoConfiguration.java +++ b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/java/org/springframework/ai/cohere/autoconfigure/CohereChatAutoConfiguration.java @@ -81,12 +81,12 @@ private CohereApi cohereApi(String apiKey, String commonApiKey, String baseUrl, Assert.hasText(resoledBaseUrl, "Cohere base URL must be set"); return CohereApi.builder() - .baseUrl(resoledBaseUrl) - .apiKey(resolvedApiKey) - .restClientBuilder(restClientBuilder) - .webClientBuilder(webClientBuilder) - .responseErrorHandler(responseErrorHandler) - .build(); + .baseUrl(resoledBaseUrl) + .apiKey(resolvedApiKey) + .restClientBuilder(restClientBuilder) + .webClientBuilder(webClientBuilder) + .responseErrorHandler(responseErrorHandler) + .build(); } } diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/java/org/springframework/ai/cohere/autoconfigure/CohereEmbeddingAutoConfiguration.java b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/java/org/springframework/ai/cohere/autoconfigure/CohereEmbeddingAutoConfiguration.java index 2187dee58dd..0207a23a723 100644 --- a/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/java/org/springframework/ai/cohere/autoconfigure/CohereEmbeddingAutoConfiguration.java +++ b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/java/org/springframework/ai/cohere/autoconfigure/CohereEmbeddingAutoConfiguration.java @@ -52,10 +52,10 @@ public class CohereEmbeddingAutoConfiguration { @Bean @ConditionalOnMissingBean public CohereEmbeddingModel mistralAiEmbeddingModel(CohereCommonProperties commonProperties, - CohereEmbeddingProperties embeddingProperties, - ObjectProvider restClientBuilderProvider, RetryTemplate retryTemplate, - ResponseErrorHandler responseErrorHandler, ObjectProvider observationRegistry, - ObjectProvider observationConvention) { + CohereEmbeddingProperties embeddingProperties, ObjectProvider restClientBuilderProvider, + RetryTemplate retryTemplate, ResponseErrorHandler responseErrorHandler, + ObjectProvider observationRegistry, + ObjectProvider observationConvention) { var cohereApi = cohereApi(embeddingProperties.getApiKey(), commonProperties.getApiKey(), embeddingProperties.getBaseUrl(), commonProperties.getBaseUrl(), @@ -75,7 +75,7 @@ public CohereEmbeddingModel mistralAiEmbeddingModel(CohereCommonProperties commo } private CohereApi cohereApi(String apiKey, String commonApiKey, String baseUrl, String commonBaseUrl, - RestClient.Builder restClientBuilder, ResponseErrorHandler responseErrorHandler) { + RestClient.Builder restClientBuilder, ResponseErrorHandler responseErrorHandler) { var resolvedApiKey = StringUtils.hasText(apiKey) ? apiKey : commonApiKey; var resoledBaseUrl = StringUtils.hasText(baseUrl) ? baseUrl : commonBaseUrl; @@ -84,11 +84,11 @@ private CohereApi cohereApi(String apiKey, String commonApiKey, String baseUrl, Assert.hasText(resoledBaseUrl, "Cohere base URL must be set"); return CohereApi.builder() - .baseUrl(resoledBaseUrl) - .apiKey(resolvedApiKey) - .restClientBuilder(restClientBuilder) - .responseErrorHandler(responseErrorHandler) - .build(); + .baseUrl(resoledBaseUrl) + .apiKey(resolvedApiKey) + .restClientBuilder(restClientBuilder) + .responseErrorHandler(responseErrorHandler) + .build(); } } diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/test/java/org/springframework/ai/cohere/autoconfigure/CohereAutoConfigurationIT.java b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/test/java/org/springframework/ai/cohere/autoconfigure/CohereAutoConfigurationIT.java index 3e4a66aa9dd..e5ff88345a8 100644 --- a/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/test/java/org/springframework/ai/cohere/autoconfigure/CohereAutoConfigurationIT.java +++ b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/test/java/org/springframework/ai/cohere/autoconfigure/CohereAutoConfigurationIT.java @@ -39,21 +39,20 @@ void generate() { @Test void embedding() { - this.contextRunner - .withConfiguration(SpringAiTestAutoConfigurations.of(CohereEmbeddingAutoConfiguration.class)) - .run(context -> { - CohereEmbeddingModel embeddingModel = context.getBean(CohereEmbeddingModel.class); - - EmbeddingResponse embeddingResponse = embeddingModel - .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(embeddingModel.dimensions()).isEqualTo(1536); - }); + this.contextRunner.withConfiguration(SpringAiTestAutoConfigurations.of(CohereEmbeddingAutoConfiguration.class)) + .run(context -> { + CohereEmbeddingModel embeddingModel = context.getBean(CohereEmbeddingModel.class); + + EmbeddingResponse embeddingResponse = embeddingModel + .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(embeddingModel.dimensions()).isEqualTo(1536); + }); } } diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/test/java/org/springframework/ai/cohere/autoconfigure/CohereModelConfigurationTests.java b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/test/java/org/springframework/ai/cohere/autoconfigure/CohereModelConfigurationTests.java index aa28ef1781b..4793b92780e 100644 --- a/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/test/java/org/springframework/ai/cohere/autoconfigure/CohereModelConfigurationTests.java +++ b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/test/java/org/springframework/ai/cohere/autoconfigure/CohereModelConfigurationTests.java @@ -16,12 +16,12 @@ public class CohereModelConfigurationTests { private final ApplicationContextRunner chatContextRunner = new ApplicationContextRunner() - .withPropertyValues("spring.ai.cohere.apiKey=" + System.getenv("COHERE_API_KEY")) - .withConfiguration(SpringAiTestAutoConfigurations.of(CohereChatAutoConfiguration.class)); + .withPropertyValues("spring.ai.cohere.apiKey=" + System.getenv("COHERE_API_KEY")) + .withConfiguration(SpringAiTestAutoConfigurations.of(CohereChatAutoConfiguration.class)); private final ApplicationContextRunner embeddingContextRunner = new ApplicationContextRunner() - .withPropertyValues("spring.ai.cohere.apiKey=" + System.getenv("COHERE_API_KEY")) - .withConfiguration(SpringAiTestAutoConfigurations.of(CohereEmbeddingAutoConfiguration.class)); + .withPropertyValues("spring.ai.cohere.apiKey=" + System.getenv("COHERE_API_KEY")) + .withConfiguration(SpringAiTestAutoConfigurations.of(CohereEmbeddingAutoConfiguration.class)); @Test void chatModelActivation() { @@ -33,24 +33,24 @@ void chatModelActivation() { }); this.chatContextRunner.withPropertyValues("spring.ai.model.chat=none", "spring.ai.model.embedding=none") - .run(context -> { - assertThat(context.getBeansOfType(CohereChatProperties.class)).isEmpty(); - assertThat(context.getBeansOfType(CohereChatModel.class)).isEmpty(); - }); + .run(context -> { + assertThat(context.getBeansOfType(CohereChatProperties.class)).isEmpty(); + assertThat(context.getBeansOfType(CohereChatModel.class)).isEmpty(); + }); this.chatContextRunner.withPropertyValues("spring.ai.model.chat=cohere", "spring.ai.model.embedding=none") - .run(context -> { - assertThat(context.getBeansOfType(CohereChatProperties.class)).isNotEmpty(); - assertThat(context.getBeansOfType(CohereChatModel.class)).isNotEmpty(); - assertThat(context.getBeansOfType(CohereEmbeddingProperties.class)).isEmpty(); - assertThat(context.getBeansOfType(CohereEmbeddingModel.class)).isEmpty(); - }); + .run(context -> { + assertThat(context.getBeansOfType(CohereChatProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(CohereChatModel.class)).isNotEmpty(); + assertThat(context.getBeansOfType(CohereEmbeddingProperties.class)).isEmpty(); + assertThat(context.getBeansOfType(CohereEmbeddingModel.class)).isEmpty(); + }); } @Test void embeddingModelActivation() { this.embeddingContextRunner - .run(context -> assertThat(context.getBeansOfType(CohereEmbeddingModel.class)).isNotEmpty()); + .run(context -> assertThat(context.getBeansOfType(CohereEmbeddingModel.class)).isNotEmpty()); this.embeddingContextRunner.withPropertyValues("spring.ai.model.embedding=none").run(context -> { assertThat(context.getBeansOfType(CohereEmbeddingProperties.class)).isEmpty(); diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/test/java/org/springframework/ai/cohere/autoconfigure/CoherePropertiesTests.java b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/test/java/org/springframework/ai/cohere/autoconfigure/CoherePropertiesTests.java index b6da9dc58b6..89e5a84eb45 100644 --- a/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/test/java/org/springframework/ai/cohere/autoconfigure/CoherePropertiesTests.java +++ b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/test/java/org/springframework/ai/cohere/autoconfigure/CoherePropertiesTests.java @@ -65,66 +65,67 @@ public void chatOptionsTest() { public void embeddingProperties() { new ApplicationContextRunner() - .withPropertyValues("spring.ai.cohere.base-url=TEST_BASE_URL", "spring.ai.cohere.api-key=abc123", - "spring.ai.cohere.embedding.options.model=MODEL_XYZ") - .withConfiguration(SpringAiTestAutoConfigurations.of(CohereEmbeddingAutoConfiguration.class)) - .run(context -> { - var embeddingProperties = context.getBean(CohereEmbeddingProperties.class); - var connectionProperties = context.getBean(CohereCommonProperties.class); + .withPropertyValues("spring.ai.cohere.base-url=TEST_BASE_URL", "spring.ai.cohere.api-key=abc123", + "spring.ai.cohere.embedding.options.model=MODEL_XYZ") + .withConfiguration(SpringAiTestAutoConfigurations.of(CohereEmbeddingAutoConfiguration.class)) + .run(context -> { + var embeddingProperties = context.getBean(CohereEmbeddingProperties.class); + var connectionProperties = context.getBean(CohereCommonProperties.class); - assertThat(connectionProperties.getApiKey()).isEqualTo("abc123"); - assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL"); + assertThat(connectionProperties.getApiKey()).isEqualTo("abc123"); + assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL"); - assertThat(embeddingProperties.getApiKey()).isNull(); - assertThat(embeddingProperties.getBaseUrl()).isEqualTo(CohereCommonProperties.DEFAULT_BASE_URL); + assertThat(embeddingProperties.getApiKey()).isNull(); + assertThat(embeddingProperties.getBaseUrl()).isEqualTo(CohereCommonProperties.DEFAULT_BASE_URL); - assertThat(embeddingProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); - }); + assertThat(embeddingProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); + }); } @Test public void embeddingOverrideConnectionProperties() { - new ApplicationContextRunner().withPropertyValues("spring.ai.cohere.base-url=TEST_BASE_URL", - "spring.ai.cohere.api-key=abc123", "spring.ai.cohere.embedding.base-url=TEST_BASE_URL2", - "spring.ai.cohere.embedding.api-key=456", "spring.ai.cohere.embedding.options.model=MODEL_XYZ") - .withConfiguration(SpringAiTestAutoConfigurations.of(CohereEmbeddingAutoConfiguration.class)) - .run(context -> { - var embeddingProperties = context.getBean(CohereEmbeddingProperties.class); - var connectionProperties = context.getBean(CohereCommonProperties.class); + new ApplicationContextRunner() + .withPropertyValues("spring.ai.cohere.base-url=TEST_BASE_URL", "spring.ai.cohere.api-key=abc123", + "spring.ai.cohere.embedding.base-url=TEST_BASE_URL2", "spring.ai.cohere.embedding.api-key=456", + "spring.ai.cohere.embedding.options.model=MODEL_XYZ") + .withConfiguration(SpringAiTestAutoConfigurations.of(CohereEmbeddingAutoConfiguration.class)) + .run(context -> { + var embeddingProperties = context.getBean(CohereEmbeddingProperties.class); + var connectionProperties = context.getBean(CohereCommonProperties.class); - assertThat(connectionProperties.getApiKey()).isEqualTo("abc123"); - assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL"); + 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.getApiKey()).isEqualTo("456"); + assertThat(embeddingProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL2"); - assertThat(embeddingProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); - }); + assertThat(embeddingProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); + }); } @Test public void embeddingOptionsTest() { new ApplicationContextRunner() - .withPropertyValues("spring.ai.cohere.api-key=API_KEY", "spring.ai.cohere.base-url=TEST_BASE_URL", - "spring.ai.cohere.embedding.options.model=MODEL_XYZ", - "spring.ai.cohere.embedding.options.embedding-types[0]=FLOAT", - "spring.ai.cohere.embedding.options.input-type=search_document", - "spring.ai.cohere.embedding.options.truncate=END") - .withConfiguration(SpringAiTestAutoConfigurations.of(CohereEmbeddingAutoConfiguration.class)) - .run(context -> { - var connectionProperties = context.getBean(CohereCommonProperties.class); - var embeddingProperties = context.getBean(CohereEmbeddingProperties.class); - - assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL"); - assertThat(connectionProperties.getApiKey()).isEqualTo("API_KEY"); - - assertThat(embeddingProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); - assertThat(embeddingProperties.getOptions().getEmbeddingTypes().get(0).name()).isEqualTo("FLOAT"); - assertThat(embeddingProperties.getOptions().getTruncate().name()).isEqualTo("END"); - assertThat(embeddingProperties.getOptions().getInputType().name()).isEqualTo("SEARCH_DOCUMENT"); - }); + .withPropertyValues("spring.ai.cohere.api-key=API_KEY", "spring.ai.cohere.base-url=TEST_BASE_URL", + "spring.ai.cohere.embedding.options.model=MODEL_XYZ", + "spring.ai.cohere.embedding.options.embedding-types[0]=FLOAT", + "spring.ai.cohere.embedding.options.input-type=search_document", + "spring.ai.cohere.embedding.options.truncate=END") + .withConfiguration(SpringAiTestAutoConfigurations.of(CohereEmbeddingAutoConfiguration.class)) + .run(context -> { + var connectionProperties = context.getBean(CohereCommonProperties.class); + var embeddingProperties = context.getBean(CohereEmbeddingProperties.class); + + assertThat(connectionProperties.getBaseUrl()).isEqualTo("TEST_BASE_URL"); + assertThat(connectionProperties.getApiKey()).isEqualTo("API_KEY"); + + assertThat(embeddingProperties.getOptions().getModel()).isEqualTo("MODEL_XYZ"); + assertThat(embeddingProperties.getOptions().getEmbeddingTypes().get(0).name()).isEqualTo("FLOAT"); + assertThat(embeddingProperties.getOptions().getTruncate().name()).isEqualTo("END"); + assertThat(embeddingProperties.getOptions().getInputType().name()).isEqualTo("SEARCH_DOCUMENT"); + }); } } diff --git a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/api/CohereApi.java b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/api/CohereApi.java index 2042e64885c..e9064267cd2 100644 --- a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/api/CohereApi.java +++ b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/api/CohereApi.java @@ -148,7 +148,8 @@ public String getName() { * Usage statistics. */ @JsonInclude(JsonInclude.Include.NON_NULL) - public record Usage(@JsonProperty("billedUnits") BilledUnits billedUnits, @JsonProperty("tokens") Tokens tokens, @JsonProperty("cached_tokens") Integer cachedTokens) { + public record Usage(@JsonProperty("billedUnits") BilledUnits billedUnits, @JsonProperty("tokens") Tokens tokens, + @JsonProperty("cached_tokens") Integer cachedTokens) { /** * Bille units * @@ -353,8 +354,11 @@ public enum ToolChoice { */ public record ChatCompletionMessage(@JsonProperty("content") Object rawContent, @JsonProperty("role") Role role, @JsonProperty("tool_plan") String toolPlan, - @JsonFormat(with = JsonFormat.Feature.ACCEPT_SINGLE_VALUE_AS_ARRAY) @JsonProperty("tool_calls") List toolCalls, - @JsonFormat(with = JsonFormat.Feature.ACCEPT_SINGLE_VALUE_AS_ARRAY) @JsonProperty("citations") List citations, @JsonProperty("tool_call_id") String toolCallId) { + @JsonFormat( + with = JsonFormat.Feature.ACCEPT_SINGLE_VALUE_AS_ARRAY) @JsonProperty("tool_calls") List toolCalls, + @JsonFormat( + with = JsonFormat.Feature.ACCEPT_SINGLE_VALUE_AS_ARRAY) @JsonProperty("citations") List citations, + @JsonProperty("tool_call_id") String toolCallId) { public ChatCompletionMessage(Object content, Role role) { this(content, role, null, null, null, null); @@ -908,46 +912,53 @@ public String getValue() { } - /** * Embedding type */ public enum EmbeddingType { + /** - * Use this when you want to get back the default float embeddings. Supported with all Embed models. + * Use this when you want to get back the default float embeddings. Supported with + * all Embed models. */ @JsonProperty("float") FLOAT, /** - * Use this when you want to get back signed int8 embeddings. Supported with Embed v3.0 and newer Embed models. + * Use this when you want to get back signed int8 embeddings. Supported with Embed + * v3.0 and newer Embed models. */ @JsonProperty("int8") INT8, /** - * Use this when you want to get back unsigned int8 embeddings. Supported with Embed v3.0 and newer Embed models. + * Use this when you want to get back unsigned int8 embeddings. Supported with + * Embed v3.0 and newer Embed models. */ @JsonProperty("uint8") UINT8, /** - * Use this when you want to get back signed binary embeddings. Supported with Embed v3.0 and newer Embed models. + * Use this when you want to get back signed binary embeddings. Supported with + * Embed v3.0 and newer Embed models. */ @JsonProperty("binary") BINARY, /** - * Use this when you want to get back unsigned binary embeddings. Supported with Embed v3.0 and newer Embed models. + * Use this when you want to get back unsigned binary embeddings. Supported with + * Embed v3.0 and newer Embed models. */ @JsonProperty("ubinary") UBINARY, /** - * Use this when you want to get back base64 embeddings. Supported with Embed v3.0 and newer Embed models. + * Use this when you want to get back base64 embeddings. Supported with Embed v3.0 + * and newer Embed models. */ @JsonProperty("base64") BASE64 + } /** @@ -965,7 +976,7 @@ public enum EmbeddingType { */ @JsonInclude(JsonInclude.Include.NON_NULL) public record EmbeddingRequest( - // @formatter:off + // @formatter:off @JsonProperty("texts") List texts, @JsonProperty("model") String model, @JsonProperty("input_type") InputType inputType, @@ -980,9 +991,13 @@ public static Builder builder() { public static final class Builder { private String model = EmbeddingModel.EMBED_V4.getValue(); + private List texts; + private InputType inputType = InputType.SEARCH_DOCUMENT; + private List embeddingTypes = List.of(EmbeddingType.FLOAT); + private Truncate truncate = Truncate.END; public Builder model(String model) { @@ -998,7 +1013,8 @@ public Builder texts(Object raw) { if (raw instanceof List list) { this.texts = (List) list; - } else { + } + else { this.texts = List.of((T) raw); } return this; @@ -1020,14 +1036,9 @@ public Builder truncate(Truncate truncate) { } public EmbeddingRequest build() { - return new EmbeddingRequest<>( - texts, - model, - inputType, - embeddingTypes, - truncate - ); + return new EmbeddingRequest<>(texts, model, inputType, embeddingTypes, truncate); } + } /** @@ -1047,6 +1058,7 @@ public enum InputType { @JsonProperty("image") IMAGE // @formatter:on + } /** @@ -1073,12 +1085,13 @@ public enum Truncate { * @param id Unique identifier for the embedding request. * @param embeddings The embeddings * @param texts The texts that were embedded. - * @param responseType The type of response ("embeddings_floats" or "embeddings_by_type"). + * @param responseType The type of response ("embeddings_floats" or + * "embeddings_by_type"). */ @JsonInclude(JsonInclude.Include.NON_NULL) @JsonIgnoreProperties(ignoreUnknown = true) public record EmbeddingResponse( - // @formatter:off + // @formatter:off @JsonProperty("id") String id, @JsonProperty("embeddings") Object embeddings, @JsonProperty("texts") List texts, @@ -1086,10 +1099,10 @@ public record EmbeddingResponse( // @formatter:on /** - * Extracts float embeddings from the response. - * Handles both response formats: - * - "embeddings_floats": embeddings is List<List<Double>> - * - "embeddings_by_type": embeddings is Map with "float" key containing List<List<Double>> + * Extracts float embeddings from the response. Handles both response formats: - + * "embeddings_floats": embeddings is List<List<Double>> - + * "embeddings_by_type": embeddings is Map with "float" key containing + * List<List<Double>> * @return List of float arrays representing the embeddings */ @JsonIgnore @@ -1099,40 +1112,37 @@ public List getFloatEmbeddings() { return List.of(); } - // Handle "embeddings_floats" format: embeddings is directly List> + // Handle "embeddings_floats" format: embeddings is directly + // List> if (this.embeddings instanceof List embeddingsList) { - return embeddingsList.stream() - .map(embedding -> { - if (embedding instanceof List embeddingVector) { - float[] floatArray = new float[embeddingVector.size()]; - for (int i = 0; i < embeddingVector.size(); i++) { - Object value = embeddingVector.get(i); - floatArray[i] = (value instanceof Number number) ? number.floatValue() : 0f; - } - return floatArray; - } - return new float[0]; - }) - .toList(); + return embeddingsList.stream().map(embedding -> { + if (embedding instanceof List embeddingVector) { + float[] floatArray = new float[embeddingVector.size()]; + for (int i = 0; i < embeddingVector.size(); i++) { + Object value = embeddingVector.get(i); + floatArray[i] = (value instanceof Number number) ? number.floatValue() : 0f; + } + return floatArray; + } + return new float[0]; + }).toList(); } // Handle "embeddings_by_type" format: embeddings is Map if (this.embeddings instanceof Map embeddingsMap) { Object floatEmbeddings = embeddingsMap.get("float"); if (floatEmbeddings instanceof List embeddingsList) { - return embeddingsList.stream() - .map(embedding -> { - if (embedding instanceof List embeddingVector) { - float[] floatArray = new float[embeddingVector.size()]; - for (int i = 0; i < embeddingVector.size(); i++) { - Object value = embeddingVector.get(i); - floatArray[i] = (value instanceof Number number) ? number.floatValue() : 0f; - } - return floatArray; - } - return new float[0]; - }) - .toList(); + return embeddingsList.stream().map(embedding -> { + if (embedding instanceof List embeddingVector) { + float[] floatArray = new float[embeddingVector.size()]; + for (int i = 0; i < embeddingVector.size(); i++) { + Object value = embeddingVector.get(i); + floatArray[i] = (value instanceof Number number) ? number.floatValue() : 0f; + } + return floatArray; + } + return new float[0]; + }).toList(); } } @@ -1160,12 +1170,12 @@ public ResponseEntity embeddings(EmbeddingRequest embe Assert.isTrue(embeddingRequest.texts.size() <= 96, "The list must be 96 items or less"); return this.restClient.post() - .uri("/v2/embed") - .body(embeddingRequest) - .retrieve() - .toEntity(new ParameterizedTypeReference<>() { + .uri("/v2/embed") + .body(embeddingRequest) + .retrieve() + .toEntity(new ParameterizedTypeReference<>() { - }); + }); } /** @@ -1180,43 +1190,38 @@ public Flux chatCompletionStream(ChatCompletionRequest chat Assert.isTrue(chatRequest.stream(), "Request must set the stream property to true."); return this.webClient.post() - .uri("v2/chat") - .body(Mono.just(chatRequest), ChatCompletionRequest.class) - .retrieve() - .bodyToFlux(String.class) - .takeUntil(SSE_DONE_PREDICATE) - .filter(SSE_DONE_PREDICATE.negate()) - .map(content -> ModelOptionsUtils.jsonToObject(content, ChatCompletionChunk.class)) - .groupBy(chunk -> chunk.id() != null ? chunk.id() : "no-id") - .flatMap(group -> - group.reduce( - new ChatCompletionChunk(null, null, null, null), - this.chunkMerger::merge - ) - .filter(chunk -> EventType.MESSAGE_END.value.equals(chunk.type()) || - (chunk.delta() != null && chunk.delta().finishReason() != null)) - ) - .map(chunkMerger::sanitizeToolCalls) - .filter(chunkMerger::hasValidToolCallsOnly) - .filter(Objects::nonNull); + .uri("v2/chat") + .body(Mono.just(chatRequest), ChatCompletionRequest.class) + .retrieve() + .bodyToFlux(String.class) + .takeUntil(SSE_DONE_PREDICATE) + .filter(SSE_DONE_PREDICATE.negate()) + .map(content -> ModelOptionsUtils.jsonToObject(content, ChatCompletionChunk.class)) + .groupBy(chunk -> chunk.id() != null ? chunk.id() : "no-id") + .flatMap(group -> group.reduce(new ChatCompletionChunk(null, null, null, null), this.chunkMerger::merge) + .filter(chunk -> EventType.MESSAGE_END.value.equals(chunk.type()) + || (chunk.delta() != null && chunk.delta().finishReason() != null))) + .map(chunkMerger::sanitizeToolCalls) + .filter(chunkMerger::hasValidToolCallsOnly) + .filter(Objects::nonNull); } public enum EventType { - MESSAGE_END("message-end"), - CONTENT_START("content-start"), - CONTENT_DELTA("content-delta"), - CONTENT_END("content-end"), - TOOL_PLAN_DELTA("tool-plan-delta"), - TOOL_CALL_START("tool-call-start"), - TOOL_CALL_DELTA("tool-call-delta"), - CITATION_START("citation-start"); + + MESSAGE_END("message-end"), CONTENT_START("content-start"), CONTENT_DELTA("content-delta"), + CONTENT_END("content-end"), TOOL_PLAN_DELTA("tool-plan-delta"), TOOL_CALL_START("tool-call-start"), + TOOL_CALL_DELTA("tool-call-delta"), CITATION_START("citation-start"); + public final String value; + EventType(String value) { this.value = value; } + public String getValue() { return this.value; } + } public static Builder builder() { diff --git a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/api/CohereStreamFunctionCallingHelper.java b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/api/CohereStreamFunctionCallingHelper.java index 08cecf36089..00a25203ee5 100644 --- a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/api/CohereStreamFunctionCallingHelper.java +++ b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/api/CohereStreamFunctionCallingHelper.java @@ -56,8 +56,7 @@ public ChatCompletionChunk merge(ChatCompletionChunk previous, ChatCompletionChu ChatCompletionMessage previousMessage = previousDelta != null ? previousDelta.message() : null; ChatCompletionMessage currentMessage = currentDelta != null ? currentDelta.message() : null; - Role role = previousMessage != null && previousMessage.role() != null - ? previousMessage.role() + Role role = previousMessage != null && previousMessage.role() != null ? previousMessage.role() : (currentMessage != null ? currentMessage.role() : null); String previousText = previousMessage != null ? extractTextFromRawContent(previousMessage.rawContent()) : ""; @@ -68,9 +67,11 @@ public ChatCompletionChunk merge(ChatCompletionChunk previous, ChatCompletionChu String mergedText; if (CONTENT_START.getValue().equals(currentType)) { mergedText = currentText; - } else if (CONTENT_END.getValue().equals(currentType)) { + } + else if (CONTENT_END.getValue().equals(currentType)) { mergedText = previousText; - } else { + } + else { mergedText = previousText + currentText; } @@ -87,29 +88,16 @@ public ChatCompletionChunk merge(ChatCompletionChunk previous, ChatCompletionChu List citations = mergeCitations(previous, current); + ChatCompletionMessage mergedMessage = new ChatCompletionMessage(mergedText, role, mergedToolPlan, + mergedToolCalls, citations, null); - ChatCompletionMessage mergedMessage = new ChatCompletionMessage( - mergedText, - role, - mergedToolPlan, - mergedToolCalls, - citations, - null - ); - - var finishReason = (currentDelta != null && currentDelta.finishReason() != null) - ? currentDelta.finishReason() + var finishReason = (currentDelta != null && currentDelta.finishReason() != null) ? currentDelta.finishReason() : (previousDelta != null ? previousDelta.finishReason() : null); - var usage = (currentDelta != null && currentDelta.usage() != null) - ? currentDelta.usage() + var usage = (currentDelta != null && currentDelta.usage() != null) ? currentDelta.usage() : (previousDelta != null ? previousDelta.usage() : null); - var mergedDelta = new ChatCompletionChunk.ChunkDelta( - mergedMessage, - finishReason, - usage - ); + var mergedDelta = new ChatCompletionChunk.ChunkDelta(mergedMessage, finishReason, usage); String id = current.id() != null ? current.id() : previous.id(); String type = current.type() != null ? current.type() : previous.type(); @@ -125,25 +113,18 @@ public ChatCompletionChunk sanitizeToolCalls(ChatCompletionChunk chunk) { ChatCompletionMessage msg = chunk.delta().message(); List toolCalls = msg.toolCalls(); - if (toolCalls == null || toolCalls.isEmpty()) return chunk; + if (toolCalls == null || toolCalls.isEmpty()) + return chunk; - List cleaned = - toolCalls.stream().filter(this::isValidToolCall).toList(); + List cleaned = toolCalls.stream().filter(this::isValidToolCall).toList(); ChatCompletionChunk.ChunkDelta oldDelta = chunk.delta(); - ChatCompletionMessage cleanedMsg = - new ChatCompletionMessage( - msg.rawContent(), - msg.role(), - msg.toolPlan(), - cleaned.isEmpty() ? null : cleaned, - msg.citations(), - null - ); + ChatCompletionMessage cleanedMsg = new ChatCompletionMessage(msg.rawContent(), msg.role(), msg.toolPlan(), + cleaned.isEmpty() ? null : cleaned, msg.citations(), null); - ChatCompletionChunk.ChunkDelta newDelta = - new ChatCompletionChunk.ChunkDelta(cleanedMsg, oldDelta.finishReason(), oldDelta.usage()); + ChatCompletionChunk.ChunkDelta newDelta = new ChatCompletionChunk.ChunkDelta(cleanedMsg, + oldDelta.finishReason(), oldDelta.usage()); return new ChatCompletionChunk(chunk.id(), chunk.type(), chunk.index(), newDelta); } @@ -157,7 +138,8 @@ public boolean hasValidToolCallsOnly(ChatCompletionChunk c) { boolean hasValidToolCalls = calls != null && calls.stream().anyMatch(this::isValidToolCall); - boolean hasTextContent = message.rawContent() != null && !extractTextFromRawContent(message.rawContent()).isEmpty(); + boolean hasTextContent = message.rawContent() != null + && !extractTextFromRawContent(message.rawContent()).isEmpty(); boolean hasCitations = message.citations() != null && !message.citations().isEmpty(); @@ -180,30 +162,32 @@ private String extractTextFromRawContent(Object rawContent) { } if (rawContent instanceof Map map) { Object text = map.get("text"); - if (text != null) return text.toString(); + if (text != null) + return text.toString(); } if (rawContent instanceof List list) { StringBuilder sb = new StringBuilder(); for (Object item : list) { if (item instanceof Map m) { Object text = m.get("text"); - if (text != null) sb.append(text); - } else if (item instanceof String s) { + if (text != null) + sb.append(text); + } + else if (item instanceof String s) { sb.append(s); } } return sb.toString(); } - if (rawContent instanceof String s) return s; + if (rawContent instanceof String s) + return s; return rawContent.toString(); } - private List mergeToolCalls(ChatCompletionChunk previous, - ChatCompletionChunk current) { + private List mergeToolCalls(ChatCompletionChunk previous, ChatCompletionChunk current) { ChatCompletionMessage previousMessage = previous != null && previous.delta() != null ? previous.delta().message() : null; - ChatCompletionMessage currentMessage = current.delta() != null - ? current.delta().message() : null; + ChatCompletionMessage currentMessage = current.delta() != null ? current.delta().message() : null; List merged = ensureToolCallList(previousMessage != null ? previousMessage.toolCalls() : null); @@ -215,8 +199,7 @@ private List mergeToolCalls(ChatCompletionChunk previous, } ToolCall existing = ensureToolCallAtIndex(merged, index); - ChatCompletionFunction existingFunction = existing.function() != null - ? existing.function() + ChatCompletionFunction existingFunction = existing.function() != null ? existing.function() : new ChatCompletionFunction(null, ""); String id = existing.id(); @@ -228,8 +211,7 @@ private List mergeToolCalls(ChatCompletionChunk previous, && !currentMessage.toolCalls().isEmpty()) { ToolCall start = currentMessage.toolCalls().get(0); - ChatCompletionFunction startFunction = start.function() != null - ? start.function() + ChatCompletionFunction startFunction = start.function() != null ? start.function() : new ChatCompletionFunction(null, ""); id = start.id() != null ? start.id() : id; @@ -266,13 +248,13 @@ private String mergeToolPlan(String previous, String currentFragment) { return previous + currentFragment; } - private List mergeCitations( - ChatCompletionChunk previous, ChatCompletionChunk current) { + private List mergeCitations(ChatCompletionChunk previous, + ChatCompletionChunk current) { ChatCompletionMessage previousMessage = previous != null && previous.delta() != null ? previous.delta().message() : null; - ChatCompletionMessage currentMessage = current != null && current.delta() != null - ? current.delta().message() : null; + ChatCompletionMessage currentMessage = current != null && current.delta() != null ? current.delta().message() + : null; List merged = new ArrayList<>(); @@ -280,7 +262,8 @@ private List mergeCitations( merged.addAll(previousMessage.citations()); } - if (current != null && CITATION_START.getValue().equals(current.type()) && currentMessage != null && currentMessage.citations() != null) { + if (current != null && CITATION_START.getValue().equals(current.type()) && currentMessage != null + && currentMessage.citations() != null) { merged.addAll(currentMessage.citations()); } diff --git a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/chat/CohereChatModel.java b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/chat/CohereChatModel.java index 98070bdd153..e09a56a872c 100644 --- a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/chat/CohereChatModel.java +++ b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/chat/CohereChatModel.java @@ -22,6 +22,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.ai.chat.messages.*; import org.springframework.ai.chat.metadata.ChatGenerationMetadata; import org.springframework.ai.chat.model.MessageAggregator; import org.springframework.ai.cohere.api.CohereApi; @@ -32,10 +33,6 @@ import org.springframework.ai.cohere.api.CohereApi.ChatCompletionMessage.ToolCall; import org.springframework.ai.cohere.api.CohereApi.ChatCompletionMessage.Role; import org.springframework.ai.cohere.api.CohereApi.ChatCompletionRequest; -import org.springframework.ai.chat.messages.AssistantMessage; -import org.springframework.ai.chat.messages.SystemMessage; -import org.springframework.ai.chat.messages.ToolResponseMessage; -import org.springframework.ai.chat.messages.UserMessage; import org.springframework.ai.chat.metadata.ChatResponseMetadata; import org.springframework.ai.chat.metadata.DefaultUsage; import org.springframework.ai.chat.metadata.Usage; @@ -68,10 +65,7 @@ import reactor.core.publisher.Mono; import reactor.core.scheduler.Schedulers; -import java.util.ArrayList; -import java.util.Base64; -import java.util.List; -import java.util.Map; +import java.util.*; import java.util.concurrent.ConcurrentHashMap; /** @@ -140,13 +134,13 @@ public CohereChatModel(CohereApi cohereApi, CohereChatOptions defaultOptions, To this.toolExecutionEligibilityPredicate = toolExecutionEligibilityPredicate; } - public static ChatResponseMetadata from(CohereApi.ChatCompletion result) { + public static ChatResponseMetadata from(ChatCompletion result) { Assert.notNull(result, "Cohere ChatCompletion must not be null"); DefaultUsage usage = getDefaultUsage(result.usage()); return ChatResponseMetadata.builder().id(result.id()).usage(usage).build(); } - public static ChatResponseMetadata from(CohereApi.ChatCompletion result, Usage usage) { + public static ChatResponseMetadata from(ChatCompletion result, Usage usage) { Assert.notNull(result, "Cohere ChatCompletion must not be null"); return ChatResponseMetadata.builder().id(result.id()).usage(usage).build(); } @@ -172,9 +166,9 @@ public Flux internalStream(Prompt prompt, ChatResponse previousCha var request = createRequest(prompt, true); ChatModelObservationContext observationContext = ChatModelObservationContext.builder() - .prompt(prompt) - .provider(CohereApi.PROVIDER_NAME) - .build(); + .prompt(prompt) + .provider(CohereApi.PROVIDER_NAME) + .build(); Observation observation = ChatModelObservationDocumentation.CHAT_MODEL_OPERATION.observation( this.observationConvention, DEFAULT_OBSERVATION_CONVENTION, () -> observationContext, @@ -183,7 +177,7 @@ public Flux internalStream(Prompt prompt, ChatResponse previousCha observation.parentObservation(contextView.getOrDefault(ObservationThreadLocalAccessor.KEY, null)).start(); Flux completionChunks = this.retryTemplate - .execute(ctx -> this.cohereApi.chatCompletionStream(request)); + .execute(ctx -> this.cohereApi.chatCompletionStream(request)); // For chunked responses, only the first chunk contains the role. // The rest of the chunks with same ID share the same role. @@ -192,40 +186,39 @@ public Flux internalStream(Prompt prompt, ChatResponse previousCha // Convert the ChatCompletionChunk into a ChatCompletion to be able to reuse // the function call handling logic. Flux chatResponse = completionChunks.map(this::toChatCompletion) - .filter(chatCompletion -> chatCompletion != null && chatCompletion.message() != null) - .switchMap(chatCompletion -> Mono.just(chatCompletion).map(completion -> { - try { - @SuppressWarnings("null") - String id = completion.id(); - ChatCompletionMessage.Provider message = completion.message(); - - // Store the role for this completion ID - if (message.role() != null && id != null) { - roleMap.putIfAbsent(id, message.role().name()); - } - - List generations = message.content().stream().map(content -> { - Map metadata = Map.of( - "id", completion.id() != null ? completion.id() : "", - "role", completion.id() != null ? roleMap.getOrDefault(id, "") : "", - "finishReason", completion.finishReason() != null ? completion.finishReason().name() : ""); - return buildGeneration(content, completion, metadata); - }).toList(); - - if (completion.usage() != null) { - DefaultUsage usage = getDefaultUsage(completion.usage()); - Usage cumulativeUsage = UsageCalculator.getCumulativeUsage(usage, previousChatResponse); - return new ChatResponse(generations, from(completion, cumulativeUsage)); - } - else { - return new ChatResponse(generations); - } + .filter(chatCompletion -> chatCompletion != null && chatCompletion.message() != null) + .switchMap(chatCompletion -> Mono.just(chatCompletion).map(completion -> { + try { + @SuppressWarnings("null") + String id = completion.id(); + ChatCompletionMessage.Provider message = completion.message(); + + // Store the role for this completion ID + if (message.role() != null && id != null) { + roleMap.putIfAbsent(id, message.role().name()); + } + + List generations = message.content().stream().map(content -> { + Map metadata = Map.of("id", completion.id() != null ? completion.id() : "", + "role", completion.id() != null ? roleMap.getOrDefault(id, "") : "", "finishReason", + completion.finishReason() != null ? completion.finishReason().name() : ""); + return buildGeneration(content, completion, metadata); + }).toList(); + + if (completion.usage() != null) { + DefaultUsage usage = getDefaultUsage(completion.usage()); + Usage cumulativeUsage = UsageCalculator.getCumulativeUsage(usage, previousChatResponse); + return new ChatResponse(generations, from(completion, cumulativeUsage)); } - catch (Exception e) { - logger.error("Error processing chat completion", e); - return new ChatResponse(List.of()); + else { + return new ChatResponse(generations); } - })); + } + catch (Exception e) { + logger.error("Error processing chat completion", e); + return new ChatResponse(List.of()); + } + })); // @formatter:off Flux chatResponseFlux = chatResponse.flatMap(response -> { @@ -280,22 +273,11 @@ private ChatCompletion toChatCompletion(CohereApi.ChatCompletionChunk chunk) { List content = extractMessageContent(message.rawContent()); - provider = new ChatCompletionMessage.Provider( - content, - message.role(), - message.toolPlan(), - message.toolCalls(), - message.citations() - ); + provider = new ChatCompletionMessage.Provider(content, message.role(), message.toolPlan(), + message.toolCalls(), message.citations()); } - return new CohereApi.ChatCompletion( - chunk.id(), - delta.finishReason(), - provider, - null, - delta.usage() - ); + return new ChatCompletion(chunk.id(), delta.finishReason(), provider, null, delta.usage()); } private List extractMessageContent(Object rawContent) { @@ -380,70 +362,75 @@ private ChatResponse internalCall(Prompt prompt, ChatResponse previousChatRespon ChatCompletionRequest request = createRequest(prompt, false); ChatModelObservationContext observationContext = ChatModelObservationContext.builder() - .prompt(prompt) - .provider(CohereApi.PROVIDER_NAME) - .build(); + .prompt(prompt) + .provider(CohereApi.PROVIDER_NAME) + .build(); ChatResponse response = ChatModelObservationDocumentation.CHAT_MODEL_OPERATION - .observation(this.observationConvention, DEFAULT_OBSERVATION_CONVENTION, () -> observationContext, - this.observationRegistry) - .observe(() -> { + .observation(this.observationConvention, DEFAULT_OBSERVATION_CONVENTION, () -> observationContext, + this.observationRegistry) + .observe(() -> { - ResponseEntity completionEntity = this.retryTemplate - .execute(ctx -> this.cohereApi.chatCompletionEntity(request)); + ResponseEntity completionEntity = this.retryTemplate + .execute(ctx -> this.cohereApi.chatCompletionEntity(request)); - ChatCompletion chatCompletion = completionEntity.getBody(); + ChatCompletion chatCompletion = completionEntity.getBody(); - if (chatCompletion == null) { - logger.warn("No chat completion returned for prompt: {}", prompt); - return new ChatResponse(List.of()); - } + if (chatCompletion == null) { + logger.warn("No chat completion returned for prompt: {}", prompt); + return new ChatResponse(List.of()); + } - final Map metadata = Map.of("id", chatCompletion.id() != null ? chatCompletion.id() : "", - "role", chatCompletion.message().role() != null ? chatCompletion.message().role().name() : "", - "finishReason", - chatCompletion.finishReason() != null ? chatCompletion.finishReason().name() : ""); + final Map metadata = Map.of("id", + chatCompletion.id() != null ? chatCompletion.id() : "", "role", + chatCompletion.message().role() != null ? chatCompletion.message().role().name() : "", + "finishReason", + chatCompletion.finishReason() != null ? chatCompletion.finishReason().name() : ""); - List generations = new ArrayList<>(); + List generations = new ArrayList<>(); - if (chatCompletion.finishReason() == null) { // Just for secure - logger.warn("No chat completion finishReason returned for prompt: {}", prompt); - return new ChatResponse(List.of()); - } + if (chatCompletion.finishReason() == null) { // Just for secure + logger.warn("No chat completion finishReason returned for prompt: {}", prompt); + return new ChatResponse(List.of()); + } - if (chatCompletion.finishReason().equals(CohereApi.ChatCompletionFinishReason.TOOL_CALL)) { - var generation = buildGeneration(null, chatCompletion, metadata); - generations.add(generation); - } else { - generations = chatCompletion.message().content().stream().map(content -> buildGeneration(content, chatCompletion, metadata)).toList(); - } + if (chatCompletion.finishReason().equals(CohereApi.ChatCompletionFinishReason.TOOL_CALL)) { + var generation = buildGeneration(null, chatCompletion, metadata); + generations.add(generation); + } + else { + generations = chatCompletion.message() + .content() + .stream() + .map(content -> buildGeneration(content, chatCompletion, metadata)) + .toList(); + } - DefaultUsage usage = getDefaultUsage(completionEntity.getBody().usage()); - Usage cumulativeUsage = UsageCalculator.getCumulativeUsage(usage, previousChatResponse); + DefaultUsage usage = getDefaultUsage(completionEntity.getBody().usage()); + Usage cumulativeUsage = UsageCalculator.getCumulativeUsage(usage, previousChatResponse); - ChatResponse chatResponse = new ChatResponse(generations, - from(completionEntity.getBody(), cumulativeUsage)); + ChatResponse chatResponse = new ChatResponse(generations, + from(completionEntity.getBody(), cumulativeUsage)); - observationContext.setResponse(chatResponse); + observationContext.setResponse(chatResponse); - return chatResponse; - }); + return chatResponse; + }); if (this.toolExecutionEligibilityPredicate.isToolExecutionRequired(prompt.getOptions(), response)) { var toolExecutionResult = this.toolCallingManager.executeToolCalls(prompt, response); if (toolExecutionResult.returnDirect()) { // Return tool execution result directly to the client. return ChatResponse.builder() - .from(response) - .generations(ToolExecutionResult.buildGenerations(toolExecutionResult)) - .build(); + .from(response) + .generations(ToolExecutionResult.buildGenerations(toolExecutionResult)) + .build(); } else { // remove tools actions before ChatOptions chatOptions = CohereChatOptions.fromOptions2(prompt.getOptions().copy()); // Send the tool execution result back to the model. - return this.internalCall(new Prompt(toolExecutionResult.conversationHistory(), chatOptions), - null); + return this.internalCall(new Prompt(toolExecutionResult.conversationHistory(), chatOptions), null); } } @@ -474,49 +461,12 @@ private Generation buildGeneration(ChatCompletionMessage.MessageContent content, /** * Accessible for testing. */ - CohereApi.ChatCompletionRequest createRequest(Prompt prompt, boolean stream) { - List chatCompletionMessages = prompt.getInstructions().stream().map(message -> { - if (message instanceof UserMessage userMessage) { - Object content = message.getText(); - - if (!CollectionUtils.isEmpty(userMessage.getMedia())) { - List contentList = new ArrayList<>( - List.of(new ChatCompletionMessage.MediaContent(message.getText()))); - - contentList.addAll(userMessage.getMedia().stream().map(this::mapToMediaContent).toList()); - - content = contentList; - } - - return List.of(new ChatCompletionMessage(content, Role.USER)); - } - else if (message instanceof SystemMessage systemMessage) { - return List.of(new ChatCompletionMessage(systemMessage.getText(), Role.SYSTEM)); - } - else if (message instanceof AssistantMessage assistantMessage) { - List toolCalls = null; - if (!CollectionUtils.isEmpty(assistantMessage.getToolCalls())) { - toolCalls = assistantMessage.getToolCalls().stream().map(toolCall -> { - var function = new ChatCompletionFunction(toolCall.name(), toolCall.arguments()); - return new ToolCall(toolCall.id(), toolCall.type(), function, null); - }).toList(); - } - - return List.of(new ChatCompletionMessage(assistantMessage.getText(), Role.ASSISTANT, toolCalls)); - } - else if (message instanceof ToolResponseMessage toolResponseMessage) { - toolResponseMessage.getResponses() - .forEach(response -> Assert.isTrue(response.id() != null, "ToolResponseMessage must have an id")); - - return toolResponseMessage.getResponses() - .stream() - .map(toolResponse -> new ChatCompletionMessage(toolResponse.responseData(), Role.TOOL, toolResponse.name(), null, null, toolResponse.id())) - .toList(); - } - else { - throw new IllegalStateException("Unexpected message type: " + message); - } - }).flatMap(List::stream).toList(); + ChatCompletionRequest createRequest(Prompt prompt, boolean stream) { + List chatCompletionMessages = prompt.getInstructions() + .stream() + .map(this::convertToCohereMessage) + .flatMap(List::stream) + .toList(); var request = new ChatCompletionRequest(chatCompletionMessages, stream); @@ -534,6 +484,88 @@ else if (message instanceof ToolResponseMessage toolResponseMessage) { return request; } + /** + * Convert a Spring AI message to Cohere API message(s). + * @param message the Spring AI message to convert + * @return list of Cohere ChatCompletionMessage(s) + */ + private List convertToCohereMessage(Message message) { + return switch (message.getMessageType()) { + case USER -> convertUserMessage(message); + case ASSISTANT -> convertAssistantMessage(message); + case SYSTEM -> convertSystemMessage(message); + case TOOL -> convertToolMessage(message); + }; + } + + /** + * Convert a USER message. + */ + private List convertUserMessage(org.springframework.ai.chat.messages.Message message) { + Object content = message.getText(); + + if (message instanceof UserMessage userMessage && !CollectionUtils.isEmpty(userMessage.getMedia())) { + List contentList = new ArrayList<>( + List.of(new ChatCompletionMessage.MediaContent(message.getText()))); + + contentList.addAll(userMessage.getMedia().stream().map(this::mapToMediaContent).toList()); + + content = contentList; + } + + return List.of(new ChatCompletionMessage(content, Role.USER)); + } + + /** + * Convert an ASSISTANT message. + */ + private List convertAssistantMessage(org.springframework.ai.chat.messages.Message message) { + if (!(message instanceof AssistantMessage assistantMessage)) { + throw new IllegalArgumentException("Unsupported assistant message class: " + message.getClass().getName()); + } + + List toolCalls = null; + if (!CollectionUtils.isEmpty(assistantMessage.getToolCalls())) { + toolCalls = convertToolCalls(assistantMessage.getToolCalls()); + } + + return List.of(new ChatCompletionMessage(assistantMessage.getText(), Role.ASSISTANT, toolCalls)); + } + + /** + * Convert tool calls. + */ + private List convertToolCalls(List springToolCalls) { + return springToolCalls.stream().map(toolCall -> { + var function = new ChatCompletionFunction(toolCall.name(), toolCall.arguments()); + return new ToolCall(toolCall.id(), toolCall.type(), function, null); + }).toList(); + } + + /** + * Convert a SYSTEM message. + */ + private List convertSystemMessage(org.springframework.ai.chat.messages.Message message) { + return List.of(new ChatCompletionMessage(message.getText(), Role.SYSTEM)); + } + + /** + * Convert a TOOL response message to Cohere format. Validates that all tool responses + * have an ID. + */ + private List convertToolMessage(Message message) { + + if (!(message instanceof ToolResponseMessage toolResponseMessage)) { + throw new IllegalArgumentException("Unsupported tool message class: " + message.getClass().getName()); + } + + return toolResponseMessage.getResponses().stream().map(toolResponse -> { + Assert.notNull(toolResponse.id(), "ToolResponseMessage.ToolResponse must have an id"); + return new ChatCompletionMessage(toolResponse.responseData(), Role.TOOL, toolResponse.name(), null, null, + toolResponse.id()); + }).toList(); + } + private ChatCompletionMessage.MediaContent mapToMediaContent(Media media) { return new ChatCompletionMessage.MediaContent(new ChatCompletionMessage.MediaContent.ImageUrl( this.fromMediaData(media.getMimeType(), media.getData()))); diff --git a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/chat/CohereChatOptions.java b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/chat/CohereChatOptions.java index 9ad5b2067ca..0bdab0fe169 100644 --- a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/chat/CohereChatOptions.java +++ b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/chat/CohereChatOptions.java @@ -35,6 +35,7 @@ import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; /** @@ -374,12 +375,41 @@ public void setToolContext(Map toolContext) { this.toolContext = toolContext; } + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + CohereChatOptions that = (CohereChatOptions) o; + return Objects.equals(model, that.model) && Objects.equals(temperature, that.temperature) + && Objects.equals(p, that.p) && Objects.equals(maxTokens, that.maxTokens) + && Objects.equals(presencePenalty, that.presencePenalty) + && Objects.equals(frequencyPenalty, that.frequencyPenalty) && Objects.equals(k, that.k) + && Objects.equals(tools, that.tools) && Objects.equals(responseFormat, that.responseFormat) + && Objects.equals(safetyMode, that.safetyMode) && Objects.equals(stopSequences, that.stopSequences) + && Objects.equals(seed, that.seed) && Objects.equals(logprobs, that.logprobs) + && Objects.equals(toolChoice, that.toolChoice) && Objects.equals(strictTools, that.strictTools) + && Objects.equals(toolCallbacks, that.toolCallbacks) && Objects.equals(toolNames, that.toolNames) + && Objects.equals(internalToolExecutionEnabled, that.internalToolExecutionEnabled) + && Objects.equals(toolContext, that.toolContext); + } + + @Override + public int hashCode() { + return Objects.hash(model, temperature, p, maxTokens, presencePenalty, frequencyPenalty, k, tools, + responseFormat, safetyMode, stopSequences, seed, logprobs, toolChoice, strictTools, toolCallbacks, + toolNames, internalToolExecutionEnabled, toolContext); + } + public static Builder builder() { return new Builder(); } public static CohereChatOptions fromOptions(CohereChatOptions fromOptions) { - return CohereChatOptions.builder() + Builder builder = CohereChatOptions.builder() .model(fromOptions.getModel()) .temperature(fromOptions.getTemperature()) .maxTokens(fromOptions.getMaxTokens()) @@ -387,41 +417,55 @@ public static CohereChatOptions fromOptions(CohereChatOptions fromOptions) { .frequencyPenalty(fromOptions.getFrequencyPenalty()) .presencePenalty(fromOptions.getPresencePenalty()) .topK(fromOptions.getTopK()) - .tools(fromOptions.getTools()) .responseFormat(fromOptions.getResponseFormat()) .safetyMode(fromOptions.getSafetyMode()) - .stop(fromOptions.getStopSequences()) .seed(fromOptions.getSeed()) .logprobs(fromOptions.getLogprobs()) .toolChoice(fromOptions.getToolChoice()) .strictTools(fromOptions.getStrictTools()) - .toolCallbacks(fromOptions.getToolCallbacks()) - .toolNames(fromOptions.getToolNames()) - .internalToolExecutionEnabled(fromOptions.getInternalToolExecutionEnabled()) - .build(); + .internalToolExecutionEnabled(fromOptions.getInternalToolExecutionEnabled()); + + // Create defensive copies of collections + if (fromOptions.getTools() != null) { + builder.tools(new ArrayList<>(fromOptions.getTools())); + } + if (fromOptions.getStopSequences() != null) { + builder.stop(new ArrayList<>(fromOptions.getStopSequences())); + } + if (fromOptions.getToolCallbacks() != null) { + builder.toolCallbacks(new ArrayList<>(fromOptions.getToolCallbacks())); + } + if (fromOptions.getToolNames() != null) { + builder.toolNames(new HashSet<>(fromOptions.getToolNames())); + } + if (fromOptions.getToolContext() != null) { + builder.toolContext(new HashMap<>(fromOptions.getToolContext())); + } + + return builder.build(); } public static CohereChatOptions fromOptions2(CohereChatOptions fromOptions) { return CohereChatOptions.builder() - .model(fromOptions.getModel()) - .temperature(fromOptions.getTemperature()) - .maxTokens(fromOptions.getMaxTokens()) - .topP(fromOptions.getTopP()) - .frequencyPenalty(fromOptions.getFrequencyPenalty()) - .presencePenalty(fromOptions.getPresencePenalty()) - .topK(fromOptions.getTopK()) - .tools(null) - .responseFormat(fromOptions.getResponseFormat()) - .safetyMode(fromOptions.getSafetyMode()) - .stop(fromOptions.getStopSequences()) - .seed(fromOptions.getSeed()) - .logprobs(fromOptions.getLogprobs()) - .toolChoice(null) - .strictTools(null) - .toolCallbacks() - .toolNames() - .internalToolExecutionEnabled(null) - .build(); + .model(fromOptions.getModel()) + .temperature(fromOptions.getTemperature()) + .maxTokens(fromOptions.getMaxTokens()) + .topP(fromOptions.getTopP()) + .frequencyPenalty(fromOptions.getFrequencyPenalty()) + .presencePenalty(fromOptions.getPresencePenalty()) + .topK(fromOptions.getTopK()) + .tools(null) + .responseFormat(fromOptions.getResponseFormat()) + .safetyMode(fromOptions.getSafetyMode()) + .stop(fromOptions.getStopSequences()) + .seed(fromOptions.getSeed()) + .logprobs(fromOptions.getLogprobs()) + .toolChoice(null) + .strictTools(null) + .toolCallbacks() + .toolNames() + .internalToolExecutionEnabled(null) + .build(); } public static class Builder { diff --git a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/embedding/CohereEmbeddingModel.java b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/embedding/CohereEmbeddingModel.java index 737a6eec1ca..86fd0e09402 100644 --- a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/embedding/CohereEmbeddingModel.java +++ b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/embedding/CohereEmbeddingModel.java @@ -78,7 +78,7 @@ public class CohereEmbeddingModel extends AbstractEmbeddingModel { private EmbeddingModelObservationConvention observationConvention = DEFAULT_OBSERVATION_CONVENTION; public CohereEmbeddingModel(CohereApi cohereApi, MetadataMode metadataMode, CohereEmbeddingOptions options, - RetryTemplate retryTemplate, ObservationRegistry observationRegistry) { + RetryTemplate retryTemplate, ObservationRegistry observationRegistry) { Assert.notNull(cohereApi, "cohereApi must not be null"); Assert.notNull(metadataMode, "metadataMode must not be null"); Assert.notNull(options, "options must not be null"); @@ -141,8 +141,8 @@ public float[] embed(Document document) { return this.embed(document.getFormattedContent(this.metadataMode)); } - private EmbeddingResponseMetadata generateResponseMetadata(String model) { - return new EmbeddingResponseMetadata(model, null); + private EmbeddingResponseMetadata generateResponseMetadata(String embeddingType) { + return new EmbeddingResponseMetadata(embeddingType, null); } /** @@ -158,12 +158,12 @@ private CohereApi.EmbeddingRequest createRequest(EmbeddingRequest reques CohereEmbeddingOptions options = mergeOptions(request.getOptions(), this.defaultOptions); return CohereApi.EmbeddingRequest.builder() - .model(options.getModel()) - .inputType(options.getInputType()) - .embeddingTypes(options.getEmbeddingTypes()) - .texts(request.getInstructions()) - .truncate(options.getTruncate()) - .build(); + .model(options.getModel()) + .inputType(options.getInputType()) + .embeddingTypes(options.getEmbeddingTypes()) + .texts(request.getInstructions()) + .truncate(options.getTruncate()) + .build(); } private CohereEmbeddingOptions mergeOptions(EmbeddingOptions requestOptions, @@ -203,8 +203,8 @@ public static final class Builder { private MetadataMode metadataMode = MetadataMode.EMBED; private CohereEmbeddingOptions options = CohereEmbeddingOptions.builder() - .model(CohereApi.EmbeddingModel.EMBED_MULTILINGUAL_LIGHT_V3.getValue()) - .build(); + .model(CohereApi.EmbeddingModel.EMBED_MULTILINGUAL_LIGHT_V3.getValue()) + .build(); private RetryTemplate retryTemplate = RetryUtils.DEFAULT_RETRY_TEMPLATE; @@ -217,7 +217,7 @@ public Builder cohereApi(CohereApi cohereApi) { public Builder metadataMode(MetadataMode metadataMode) { this.metadataMode = metadataMode; - return this; + return this; } public Builder options(CohereEmbeddingOptions options) { @@ -239,6 +239,7 @@ public CohereEmbeddingModel build() { return new CohereEmbeddingModel(this.cohereApi, this.metadataMode, this.options, this.retryTemplate, this.observationRegistry); } + } } diff --git a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/embedding/CohereEmbeddingOptions.java b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/embedding/CohereEmbeddingOptions.java index c664f000dd8..e717d2bfe71 100644 --- a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/embedding/CohereEmbeddingOptions.java +++ b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/embedding/CohereEmbeddingOptions.java @@ -52,7 +52,7 @@ public class CohereEmbeddingOptions implements EmbeddingOptions { * The types of embeddings to return (float, int8, uint8, binary, ubinary). */ @JsonProperty("embedding_types") - private List embeddingTypes; + private List embeddingTypes = new ArrayList<>(); /** * How to handle inputs longer than the maximum token length (NONE, START, END). @@ -73,6 +73,11 @@ public static CohereEmbeddingOptions fromOptions(CohereEmbeddingOptions fromOpti .build(); } + private CohereEmbeddingOptions() { + this.embeddingTypes.add(EmbeddingType.FLOAT); + this.inputType = InputType.CLASSIFICATION; + } + @Override public String getModel() { return this.model; diff --git a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/schema/CohereToolCallingManager.java b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/schema/CohereToolCallingManager.java index 1c837495852..c8d9b98d0fd 100644 --- a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/schema/CohereToolCallingManager.java +++ b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/schema/CohereToolCallingManager.java @@ -70,17 +70,16 @@ public List resolveToolDefinitions(ToolCallingChatOptions chatOp List toolDefinitions = this.delegateToolCallingManager.resolveToolDefinitions(chatOptions); - /*return toolDefinitions.stream().map(td -> { - ObjectNode jsonSchema = JsonSchemaConverter.fromJson(td.inputSchema()); - ObjectNode openApiSchema = JsonSchemaConverter.convertToOpenApiSchema(jsonSchema); - JsonSchemaGenerator.convertTypeValuesToUpperCase(openApiSchema); - - return DefaultToolDefinition.builder() - .name(td.name()) - .description(td.description()) - .inputSchema(openApiSchema.toPrettyString()) - .build(); - }).toList();*/ + /* + * return toolDefinitions.stream().map(td -> { ObjectNode jsonSchema = + * JsonSchemaConverter.fromJson(td.inputSchema()); ObjectNode openApiSchema = + * JsonSchemaConverter.convertToOpenApiSchema(jsonSchema); + * JsonSchemaGenerator.convertTypeValuesToUpperCase(openApiSchema); + * + * return DefaultToolDefinition.builder() .name(td.name()) + * .description(td.description()) .inputSchema(openApiSchema.toPrettyString()) + * .build(); }).toList(); + */ return List.of(); } diff --git a/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/CohereRetryTests.java b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/CohereRetryTests.java new file mode 100644 index 00000000000..fbf2b415613 --- /dev/null +++ b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/CohereRetryTests.java @@ -0,0 +1,214 @@ +/* + * Copyright 2023-2025 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.cohere; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.cohere.api.CohereApi; +import org.springframework.ai.cohere.api.CohereApi.ChatCompletion; +import org.springframework.ai.cohere.api.CohereApi.ChatCompletionChunk; +import org.springframework.ai.cohere.api.CohereApi.ChatCompletionFinishReason; +import org.springframework.ai.cohere.api.CohereApi.ChatCompletionMessage; +import org.springframework.ai.cohere.api.CohereApi.ChatCompletionMessage.Role; +import org.springframework.ai.cohere.api.CohereApi.ChatCompletionRequest; +import org.springframework.ai.cohere.api.CohereApi.EmbeddingRequest; +import org.springframework.ai.cohere.api.CohereApi.EmbeddingResponse; +import org.springframework.ai.cohere.api.CohereApi.Usage; +import org.springframework.ai.cohere.chat.CohereChatModel; +import org.springframework.ai.cohere.chat.CohereChatOptions; +import org.springframework.ai.cohere.embedding.CohereEmbeddingModel; +import org.springframework.ai.retry.RetryUtils; +import org.springframework.ai.retry.TransientAiException; +import org.springframework.http.ResponseEntity; +import org.springframework.retry.RetryCallback; +import org.springframework.retry.RetryContext; +import org.springframework.retry.RetryListener; +import org.springframework.retry.support.RetryTemplate; +import reactor.core.publisher.Flux; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.isA; +import static org.mockito.BDDMockito.given; + +/** + * @author Ricken Bazolo + */ +@SuppressWarnings("unchecked") +@ExtendWith(MockitoExtension.class) +public class CohereRetryTests { + + private TestRetryListener retryListener; + + private RetryTemplate retryTemplate; + + private @Mock CohereApi cohereApi; + + private CohereChatModel chatModel; + + private CohereEmbeddingModel embeddingModel; + + @BeforeEach + public void beforeEach() { + this.retryTemplate = RetryUtils.SHORT_RETRY_TEMPLATE; + this.retryListener = new TestRetryListener(); + this.retryTemplate.registerListener(this.retryListener); + + this.chatModel = CohereChatModel.builder() + .cohereApi(this.cohereApi) + .defaultOptions(CohereChatOptions.builder() + .temperature(0.7) + .topP(1.0) + .model(CohereApi.ChatModel.COMMAND_A_R7B.getValue()) + .build()) + .retryTemplate(this.retryTemplate) + .build(); + this.embeddingModel = CohereEmbeddingModel.builder() + .cohereApi(this.cohereApi) + .retryTemplate(this.retryTemplate) + .build(); + } + + @Test + public void cohereChatTransientError() { + var message = new ChatCompletionMessage.Provider( + List.of(new ChatCompletionMessage.MessageContent("text", "Response", null)), Role.ASSISTANT, null, null, + null); + + ChatCompletion expectedChatCompletion = new ChatCompletion("id", ChatCompletionFinishReason.COMPLETE, message, + null, new Usage(null, new Usage.Tokens(10, 20), 10)); + + given(this.cohereApi.chatCompletionEntity(isA(ChatCompletionRequest.class))) + .willThrow(new TransientAiException("Transient Error 1")) + .willThrow(new TransientAiException("Transient Error 2")) + .willReturn(ResponseEntity.of(Optional.of(expectedChatCompletion))); + + var result = this.chatModel.call(new Prompt("text")); + + assertThat(result).isNotNull(); + assertThat(result.getResult().getOutput().getText()).isEqualTo("Response"); + assertThat(this.retryListener.onSuccessRetryCount).isEqualTo(2); + assertThat(this.retryListener.onErrorRetryCount).isEqualTo(2); + } + + @Test + public void cohereChatNonTransientError() { + given(this.cohereApi.chatCompletionEntity(isA(ChatCompletionRequest.class))) + .willThrow(new RuntimeException("Non Transient Error")); + assertThrows(RuntimeException.class, () -> this.chatModel.call(new Prompt("text"))); + } + + @Test + @Disabled("Currently stream() does not implement retry") + public void cohereChatStreamTransientError() { + var message = new ChatCompletionMessage("Response", Role.ASSISTANT); + + var delta = new ChatCompletionChunk.ChunkDelta(message, ChatCompletionFinishReason.COMPLETE, null); + + ChatCompletionChunk expectedChunk = new ChatCompletionChunk("id", "content-delta", 0, delta); + + given(this.cohereApi.chatCompletionStream(isA(ChatCompletionRequest.class))) + .willThrow(new TransientAiException("Transient Error 1")) + .willThrow(new TransientAiException("Transient Error 2")) + .willReturn(Flux.just(expectedChunk)); + + var result = this.chatModel.stream(new Prompt("text")); + + assertThat(result).isNotNull(); + assertThat(result.collectList().block().get(0).getResult().getOutput().getText()).isEqualTo("Response"); + assertThat(this.retryListener.onSuccessRetryCount).isEqualTo(2); + assertThat(this.retryListener.onErrorRetryCount).isEqualTo(2); + } + + @Test + @Disabled("Currently stream() does not implement retry") + public void cohereChatStreamNonTransientError() { + given(this.cohereApi.chatCompletionStream(isA(ChatCompletionRequest.class))) + .willThrow(new RuntimeException("Non Transient Error")); + assertThrows(RuntimeException.class, () -> this.chatModel.stream(new Prompt("text"))); + } + + @Test + @Disabled("Embedding tests need to be adapted for Cohere API structure") + public void cohereEmbeddingTransientError() { + List> embeddingsList = List.of(List.of(9.9, 8.8), List.of(7.7, 6.6)); + + EmbeddingResponse expectedEmbeddings = new EmbeddingResponse("id", embeddingsList, List.of("text1", "text2"), + "embeddings_floats"); + + given(this.cohereApi.embeddings(isA(EmbeddingRequest.class))) + .willThrow(new TransientAiException("Transient Error 1")) + .willThrow(new TransientAiException("Transient Error 2")) + .willReturn(ResponseEntity.of(Optional.of(expectedEmbeddings))); + + var result = this.embeddingModel + .call(new org.springframework.ai.embedding.EmbeddingRequest(List.of("text1", "text2"), null)); + + assertThat(result).isNotNull(); + assertThat(this.retryListener.onSuccessRetryCount).isEqualTo(2); + assertThat(this.retryListener.onErrorRetryCount).isEqualTo(2); + } + + @Test + public void cohereEmbeddingNonTransientError() { + given(this.cohereApi.embeddings(isA(EmbeddingRequest.class))) + .willThrow(new RuntimeException("Non Transient Error")); + assertThrows(RuntimeException.class, () -> this.embeddingModel + .call(new org.springframework.ai.embedding.EmbeddingRequest(List.of("text1", "text2"), null))); + } + + @Test + public void cohereChatMixedTransientAndNonTransientErrors() { + given(this.cohereApi.chatCompletionEntity(isA(ChatCompletionRequest.class))) + .willThrow(new TransientAiException("Transient Error")) + .willThrow(new RuntimeException("Non Transient Error")); + + // Should fail immediately on non-transient error, no further retries + assertThrows(RuntimeException.class, () -> this.chatModel.call(new Prompt("text"))); + + // Should have 1 retry attempt before hitting non-transient error + assertThat(this.retryListener.onErrorRetryCount).isEqualTo(2); + } + + private static class TestRetryListener implements RetryListener { + + int onErrorRetryCount = 0; + + int onSuccessRetryCount = 0; + + @Override + public void onSuccess(RetryContext context, RetryCallback callback, T result) { + this.onSuccessRetryCount = context.getRetryCount(); + } + + @Override + public void onError(RetryContext context, RetryCallback callback, + Throwable throwable) { + this.onErrorRetryCount = context.getRetryCount(); + } + + } + +} diff --git a/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/CohereTestConfiguration.java b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/CohereTestConfiguration.java index 2042f1a0c7f..526cefe0344 100644 --- a/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/CohereTestConfiguration.java +++ b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/CohereTestConfiguration.java @@ -19,6 +19,7 @@ import org.springframework.ai.cohere.api.CohereApi; import org.springframework.ai.cohere.chat.CohereChatModel; import org.springframework.ai.cohere.chat.CohereChatOptions; +import org.springframework.ai.cohere.embedding.CohereEmbeddingModel; import org.springframework.boot.SpringBootConfiguration; import org.springframework.context.annotation.Bean; import org.springframework.util.StringUtils; @@ -51,4 +52,9 @@ public CohereChatModel mistralAiChatModel(CohereApi api) { .build(); } + @Bean + public CohereEmbeddingModel mistralAiEmbeddingModel(CohereApi api) { + return CohereEmbeddingModel.builder().cohereApi(api).build(); + } + } diff --git a/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/api/CohereApiIT.java b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/api/CohereApiIT.java index ab50d002096..ee251a6fc20 100644 --- a/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/api/CohereApiIT.java +++ b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/api/CohereApiIT.java @@ -55,9 +55,7 @@ void chatCompletionEntityWithSystemMessage() { @Test void embeddings() { ResponseEntity response = this.cohereApi - .embeddings(CohereApi.EmbeddingRequest.builder() - .texts("Hello world") - .build()); + .embeddings(CohereApi.EmbeddingRequest.builder().texts("Hello world").build()); assertThat(response).isNotNull(); Assertions.assertNotNull(response.getBody()); diff --git a/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/api/tool/CohereApiToolFunctionCallIT.java b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/api/tool/CohereApiToolFunctionCallIT.java index cead57fc0ed..dfee5c31c04 100644 --- a/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/api/tool/CohereApiToolFunctionCallIT.java +++ b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/api/tool/CohereApiToolFunctionCallIT.java @@ -107,8 +107,8 @@ public void toolFunctionCall() throws JsonProcessingException { assertThat(chatCompletion.message()).isNotNull(); ChatCompletionMessage responseMessage = new ChatCompletionMessage(chatCompletion.message().content(), - chatCompletion.message().role(), chatCompletion.message().toolPlan(), chatCompletion.message().toolCalls(), chatCompletion.message().citations(), null); - + chatCompletion.message().role(), chatCompletion.message().toolPlan(), + chatCompletion.message().toolCalls(), chatCompletion.message().citations(), null); assertThat(responseMessage.role()).isEqualTo(Role.ASSISTANT); assertThat(responseMessage.toolCalls()).isNotNull(); @@ -136,8 +136,7 @@ public void toolFunctionCall() throws JsonProcessingException { var functionResponseRequest = new ChatCompletionRequest(messages, MISTRAL_AI_CHAT_MODEL, 0.8); - ResponseEntity result2 = this.completionApi - .chatCompletionEntity(functionResponseRequest); + ResponseEntity result2 = this.completionApi.chatCompletionEntity(functionResponseRequest); chatCompletion = result2.getBody(); @@ -148,12 +147,9 @@ public void toolFunctionCall() throws JsonProcessingException { var messageContent = chatCompletion.message().content().get(0); assertThat(chatCompletion.message().role()).isEqualTo(Role.ASSISTANT); - assertThat(messageContent.text()).contains("San Francisco") - .containsAnyOf("30.0", "30"); - assertThat(messageContent.text()).contains("Tokyo") - .containsAnyOf("10.0", "10"); - assertThat(messageContent.text()).contains("Paris") - .containsAnyOf("15.0", "15"); + assertThat(messageContent.text()).contains("San Francisco").containsAnyOf("30.0", "30"); + assertThat(messageContent.text()).contains("Tokyo").containsAnyOf("10.0", "10"); + assertThat(messageContent.text()).contains("Paris").containsAnyOf("15.0", "15"); } } diff --git a/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/api/tool/MockWeatherService.java b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/api/tool/MockWeatherService.java index 6a5be03fae0..b29a16e1a79 100644 --- a/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/api/tool/MockWeatherService.java +++ b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/api/tool/MockWeatherService.java @@ -24,7 +24,6 @@ import java.util.function.Function; - public class MockWeatherService implements Function { @Override diff --git a/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/api/tool/PaymentStatusFunctionCallingIT.java b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/api/tool/PaymentStatusFunctionCallingIT.java index fb277bfd7df..378ed3c2b89 100644 --- a/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/api/tool/PaymentStatusFunctionCallingIT.java +++ b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/api/tool/PaymentStatusFunctionCallingIT.java @@ -91,13 +91,15 @@ public void toolFunctionCall() throws JsonProcessingException { CohereApi cohereApi = CohereApi.builder().apiKey(System.getenv("COHERE_API_KEY")).build(); - ResponseEntity response = cohereApi.chatCompletionEntity(new ChatCompletionRequest(messages, - CohereApi.ChatModel.COMMAND_A_R7B.getValue(), List.of(paymentStatusTool, paymentDateTool), ToolChoice.REQUIRED)); + ResponseEntity response = cohereApi + .chatCompletionEntity(new ChatCompletionRequest(messages, CohereApi.ChatModel.COMMAND_A_R7B.getValue(), + List.of(paymentStatusTool, paymentDateTool), ToolChoice.REQUIRED)); ChatCompletion chatCompletion = response.getBody(); ChatCompletionMessage responseMessage = new ChatCompletionMessage(chatCompletion.message().content(), - chatCompletion.message().role(), chatCompletion.message().toolPlan(), chatCompletion.message().toolCalls(), chatCompletion.message().citations(), null); + chatCompletion.message().role(), chatCompletion.message().toolPlan(), + chatCompletion.message().toolCalls(), chatCompletion.message().citations(), null); assertThat(responseMessage.role()).isEqualTo(Role.ASSISTANT); assertThat(responseMessage.toolCalls()).isNotNull(); @@ -116,7 +118,8 @@ public void toolFunctionCall() throws JsonProcessingException { // Extend conversation with function response. // The functionName is used to identify the function response! - messages.add(new ChatCompletionMessage(result.toString(), Role.TOOL, functionName, null, responseMessage.citations(), toolCall.id())); + messages.add(new ChatCompletionMessage(result.toString(), Role.TOOL, functionName, null, + responseMessage.citations(), toolCall.id())); } response = cohereApi diff --git a/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/CohereChatClientIT.java b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/chat/CohereChatClientIT.java similarity index 98% rename from models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/CohereChatClientIT.java rename to models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/chat/CohereChatClientIT.java index b74f66c01ff..c7a4449ad97 100644 --- a/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/CohereChatClientIT.java +++ b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/chat/CohereChatClientIT.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.ai.cohere; +package org.springframework.ai.cohere.chat; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; @@ -24,8 +24,8 @@ import org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor; import org.springframework.ai.chat.messages.UserMessage; import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.cohere.CohereTestConfiguration; import org.springframework.ai.cohere.api.tool.MockWeatherService; -import org.springframework.ai.cohere.chat.CohereChatOptions; import org.springframework.ai.cohere.testutils.AbstractIT; import org.springframework.ai.converter.BeanOutputConverter; import org.springframework.ai.converter.ListOutputConverter; diff --git a/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/chat/CohereChatCompletionRequestTests.java b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/chat/CohereChatCompletionRequestTests.java new file mode 100644 index 00000000000..6c834149e5b --- /dev/null +++ b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/chat/CohereChatCompletionRequestTests.java @@ -0,0 +1,309 @@ +/* + * Copyright 2023-2025 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.cohere.chat; + +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.messages.*; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.content.Media; +import org.springframework.ai.cohere.api.CohereApi; +import org.springframework.ai.cohere.api.CohereApi.ChatCompletionMessage; +import org.springframework.ai.model.tool.ToolCallingChatOptions; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.ai.tool.definition.DefaultToolDefinition; +import org.springframework.ai.tool.definition.ToolDefinition; + +import java.net.URI; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * @author Ricken Bazolo + */ +class CohereChatCompletionRequestTests { + + private static final String BASE_URL = "https://faked.url"; + + private static final String API_KEY = "FAKED_API_KEY"; + + private static final String TEXT_CONTENT = "Hello world!"; + + private static final String IMAGE_URL = "https://example.com/image.png"; + + private static final Media IMAGE_MEDIA = new Media(Media.Format.IMAGE_PNG, URI.create(IMAGE_URL)); + + private final CohereChatModel chatModel = CohereChatModel.builder() + .cohereApi(CohereApi.builder().baseUrl(BASE_URL).apiKey(API_KEY).build()) + .build(); + + @Test + void chatCompletionDefaultRequestTest() { + var prompt = this.chatModel.buildRequestPrompt(new Prompt("test content")); + var request = this.chatModel.createRequest(prompt, false); + + assertThat(request.messages()).hasSize(1); + assertThat(request.temperature()).isEqualTo(0.3); + assertThat(request.p()).isEqualTo(1); + assertThat(request.maxTokens()).isNull(); + assertThat(request.stream()).isFalse(); + } + + @Test + void chatCompletionRequestWithOptionsTest() { + var options = CohereChatOptions.builder().temperature(0.5).topP(0.8).build(); + var prompt = this.chatModel.buildRequestPrompt(new Prompt("test content", options)); + var request = this.chatModel.createRequest(prompt, true); + + assertThat(request.messages()).hasSize(1); + assertThat(request.p()).isEqualTo(0.8); + assertThat(request.temperature()).isEqualTo(0.5); + assertThat(request.stream()).isTrue(); + } + + @Test + void whenToolRuntimeOptionsThenMergeWithDefaults() { + CohereChatOptions defaultOptions = CohereChatOptions.builder() + .model("DEFAULT_MODEL") + .internalToolExecutionEnabled(true) + .toolCallbacks(new TestToolCallback("tool1"), new TestToolCallback("tool2")) + .toolNames("tool1", "tool2") + .toolContext(Map.of("key1", "value1", "key2", "valueA")) + .build(); + + CohereChatModel anotherChatModel = CohereChatModel.builder() + .cohereApi(CohereApi.builder().baseUrl(BASE_URL).apiKey(API_KEY).build()) + .defaultOptions(defaultOptions) + .build(); + + CohereChatOptions runtimeOptions = CohereChatOptions.builder() + .internalToolExecutionEnabled(false) + .toolCallbacks(new TestToolCallback("tool3"), new TestToolCallback("tool4")) + .toolNames("tool3") + .toolContext(Map.of("key2", "valueB")) + .build(); + Prompt prompt = anotherChatModel.buildRequestPrompt(new Prompt("Test message content", runtimeOptions)); + + assertThat(((ToolCallingChatOptions) prompt.getOptions())).isNotNull(); + assertThat(((ToolCallingChatOptions) prompt.getOptions()).getInternalToolExecutionEnabled()).isFalse(); + assertThat(((ToolCallingChatOptions) prompt.getOptions()).getToolCallbacks()).hasSize(2); + assertThat(((ToolCallingChatOptions) prompt.getOptions()).getToolCallbacks() + .stream() + .map(toolCallback -> toolCallback.getToolDefinition().name())).containsExactlyInAnyOrder("tool3", "tool4"); + assertThat(((ToolCallingChatOptions) prompt.getOptions()).getToolNames()).containsExactlyInAnyOrder("tool3"); + assertThat(((ToolCallingChatOptions) prompt.getOptions()).getToolContext()).containsEntry("key1", "value1") + .containsEntry("key2", "valueB"); + } + + @Test + void createChatCompletionMessagesWithUserMessage() { + var userMessage = new UserMessage(TEXT_CONTENT); + userMessage.getMedia().add(IMAGE_MEDIA); + var prompt = createPrompt(userMessage); + var chatCompletionRequest = this.chatModel.createRequest(prompt, false); + verifyUserChatCompletionMessages(chatCompletionRequest.messages()); + } + + @Test + void createChatCompletionMessagesWithSimpleUserMessage() { + var simpleUserMessage = new SimpleMessage(MessageType.USER, TEXT_CONTENT); + var prompt = createPrompt(simpleUserMessage); + var chatCompletionRequest = this.chatModel.createRequest(prompt, false); + var chatCompletionMessages = chatCompletionRequest.messages(); + assertThat(chatCompletionMessages).hasSize(1); + var chatCompletionMessage = chatCompletionMessages.get(0); + assertThat(chatCompletionMessage.role()).isEqualTo(ChatCompletionMessage.Role.USER); + assertThat(chatCompletionMessage.content()).isEqualTo(TEXT_CONTENT); + } + + @Test + void createChatCompletionMessagesWithSystemMessage() { + var systemMessage = new SystemMessage(TEXT_CONTENT); + var prompt = createPrompt(systemMessage); + var chatCompletionRequest = this.chatModel.createRequest(prompt, false); + verifySystemChatCompletionMessages(chatCompletionRequest.messages()); + } + + @Test + void createChatCompletionMessagesWithSimpleSystemMessage() { + var simpleSystemMessage = new SimpleMessage(MessageType.SYSTEM, TEXT_CONTENT); + var prompt = createPrompt(simpleSystemMessage); + var chatCompletionRequest = this.chatModel.createRequest(prompt, false); + verifySystemChatCompletionMessages(chatCompletionRequest.messages()); + } + + @Test + void createChatCompletionMessagesWithAssistantMessage() { + var toolCall1 = createToolCall(1); + var toolCall2 = createToolCall(2); + var toolCall3 = createToolCall(3); + // @formatter:off + var assistantMessage = AssistantMessage.builder() + .content(TEXT_CONTENT) + .toolCalls(List.of(toolCall1, toolCall2, toolCall3)) + .build(); + // @formatter:on + var prompt = createPrompt(assistantMessage); + var chatCompletionRequest = this.chatModel.createRequest(prompt, false); + var chatCompletionMessages = chatCompletionRequest.messages(); + assertThat(chatCompletionMessages).hasSize(1); + var chatCompletionMessage = chatCompletionMessages.get(0); + assertThat(chatCompletionMessage.role()).isEqualTo(ChatCompletionMessage.Role.ASSISTANT); + assertThat(chatCompletionMessage.content()).isEqualTo(TEXT_CONTENT); + var toolCalls = chatCompletionMessage.toolCalls(); + assertThat(toolCalls).hasSize(3); + verifyToolCall(toolCalls.get(0), toolCall1); + verifyToolCall(toolCalls.get(1), toolCall2); + verifyToolCall(toolCalls.get(2), toolCall3); + } + + @Test + void createChatCompletionMessagesWithSimpleAssistantMessage() { + var simpleAssistantMessage = new SimpleMessage(MessageType.ASSISTANT, TEXT_CONTENT); + var prompt = createPrompt(simpleAssistantMessage); + assertThatThrownBy(() -> this.chatModel.createRequest(prompt, false)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Unsupported assistant message class: " + SimpleMessage.class.getName()); + } + + @Test + void createChatCompletionMessagesWithToolResponseMessage() { + var toolResponse1 = createToolResponse(1); + var toolResponse2 = createToolResponse(2); + var toolResponse3 = createToolResponse(3); + var toolResponseMessage = ToolResponseMessage.builder() + .responses(List.of(toolResponse1, toolResponse2, toolResponse3)) + .build(); + var prompt = createPrompt(toolResponseMessage); + var chatCompletionRequest = this.chatModel.createRequest(prompt, false); + var chatCompletionMessages = chatCompletionRequest.messages(); + assertThat(chatCompletionMessages).hasSize(3); + verifyToolChatCompletionMessage(chatCompletionMessages.get(0), toolResponse1); + verifyToolChatCompletionMessage(chatCompletionMessages.get(1), toolResponse2); + verifyToolChatCompletionMessage(chatCompletionMessages.get(2), toolResponse3); + } + + @Test + void createChatCompletionMessagesWithInvalidToolResponseMessage() { + var toolResponse = new ToolResponseMessage.ToolResponse(null, null, null); + var toolResponseMessage = ToolResponseMessage.builder().responses(List.of(toolResponse)).build(); + var prompt = createPrompt(toolResponseMessage); + assertThatThrownBy(() -> this.chatModel.createRequest(prompt, false)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("ToolResponseMessage.ToolResponse must have an id"); + } + + @Test + void createChatCompletionMessagesWithSimpleToolMessage() { + var simpleToolMessage = new SimpleMessage(MessageType.TOOL, TEXT_CONTENT); + var prompt = createPrompt(simpleToolMessage); + assertThatThrownBy(() -> this.chatModel.createRequest(prompt, false)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Unsupported tool message class: " + SimpleMessage.class.getName()); + } + + private Prompt createPrompt(Message message) { + var chatOptions = CohereChatOptions.builder().temperature(0.7d).build(); + var prompt = new Prompt(message, chatOptions); + + return this.chatModel.buildRequestPrompt(prompt); + } + + private static void verifyToolChatCompletionMessage(ChatCompletionMessage chatCompletionMessage, + ToolResponseMessage.ToolResponse toolResponse) { + assertThat(chatCompletionMessage.role()).isEqualTo(ChatCompletionMessage.Role.TOOL); + assertThat(chatCompletionMessage.content()).isEqualTo(toolResponse.responseData()); + assertThat(chatCompletionMessage.toolCalls()).isNull(); + assertThat(chatCompletionMessage.toolCallId()).isEqualTo(toolResponse.id()); + } + + private static ToolResponseMessage.ToolResponse createToolResponse(int number) { + return new ToolResponseMessage.ToolResponse("id" + number, "name" + number, "responseData" + number); + } + + private static void verifyToolCall(ChatCompletionMessage.ToolCall mistralToolCall, + AssistantMessage.ToolCall toolCall) { + assertThat(mistralToolCall.id()).isEqualTo(toolCall.id()); + assertThat(mistralToolCall.type()).isEqualTo(toolCall.type()); + var function = mistralToolCall.function(); + assertThat(function).isNotNull(); + assertThat(function.name()).isEqualTo(toolCall.name()); + assertThat(function.arguments()).isEqualTo(toolCall.arguments()); + } + + private static AssistantMessage.ToolCall createToolCall(int number) { + return new AssistantMessage.ToolCall("id" + number, "type" + number, "name" + number, "arguments " + number); + } + + private static void verifySystemChatCompletionMessages(List chatCompletionMessages) { + assertThat(chatCompletionMessages).hasSize(1); + var chatCompletionMessage = chatCompletionMessages.get(0); + assertThat(chatCompletionMessage.role()).isEqualTo(ChatCompletionMessage.Role.SYSTEM); + assertThat(chatCompletionMessage.content()).isEqualTo(TEXT_CONTENT); + } + + private static void verifyUserChatCompletionMessages(List chatCompletionMessages) { + assertThat(chatCompletionMessages).hasSize(1); + var chatCompletionMessage = chatCompletionMessages.get(0); + assertThat(chatCompletionMessage.role()).isEqualTo(ChatCompletionMessage.Role.USER); + var rawContent = chatCompletionMessage.rawContent(); + assertThat(rawContent).isNotNull(); + var maps = (List>) rawContent; + assertThat(maps).hasSize(2); + // @formatter:off + var textMap = maps.get(0); + assertThat(textMap).hasSize(2) + .containsEntry("type", "text") + .containsEntry("text", TEXT_CONTENT); + var imageUrlMap = maps.get(1); + assertThat(imageUrlMap).hasSize(2) + .containsEntry("type", "image_url") + .containsEntry("image_url", Map.of("url", IMAGE_URL)); + // @formatter:on + } + + static class SimpleMessage extends AbstractMessage { + + SimpleMessage(MessageType messageType, String textContent) { + super(messageType, textContent, Map.of()); + } + + } + + static class TestToolCallback implements ToolCallback { + + private final ToolDefinition toolDefinition; + + TestToolCallback(String name) { + this.toolDefinition = DefaultToolDefinition.builder().name(name).inputSchema("{}").build(); + } + + @Override + public ToolDefinition getToolDefinition() { + return this.toolDefinition; + } + + @Override + public String call(String toolInput) { + return "Mission accomplished!"; + } + + } + +} diff --git a/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/chat/CohereChatModelIT.java b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/chat/CohereChatModelIT.java new file mode 100644 index 00000000000..6a88fee13ec --- /dev/null +++ b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/chat/CohereChatModelIT.java @@ -0,0 +1,336 @@ +/* + * Copyright 2023-2025 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.cohere.chat; + +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.memory.ChatMemory; +import org.springframework.ai.chat.memory.MessageWindowChatMemory; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.SystemMessage; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.model.Generation; +import org.springframework.ai.chat.prompt.ChatOptions; +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.cohere.CohereTestConfiguration; +import org.springframework.ai.cohere.api.tool.MockWeatherService; +import org.springframework.ai.cohere.testutils.AbstractIT; +import org.springframework.ai.converter.BeanOutputConverter; +import org.springframework.ai.converter.ListOutputConverter; +import org.springframework.ai.converter.MapOutputConverter; +import org.springframework.ai.cohere.api.CohereApi; +import org.springframework.ai.model.tool.DefaultToolCallingManager; +import org.springframework.ai.model.tool.ToolCallingChatOptions; +import org.springframework.ai.model.tool.ToolCallingManager; +import org.springframework.ai.model.tool.ToolExecutionResult; +import org.springframework.ai.support.ToolCallbacks; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.function.FunctionToolCallback; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.core.convert.support.DefaultConversionService; +import reactor.core.publisher.Flux; + +import java.util.UUID; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Ricken Bazolo + */ +@SpringBootTest(classes = CohereTestConfiguration.class) +@EnabledIfEnvironmentVariable(named = "COHERE_API_KEY", matches = ".+") +class CohereChatModelIT extends AbstractIT { + + private static final Logger logger = LoggerFactory.getLogger(CohereChatModelIT.class); + + @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(this.systemResource); + Message systemMessage = systemPromptTemplate.createMessage(Map.of("name", "Bob", "voice", "pirate")); + Prompt prompt = new Prompt(List.of(userMessage, systemMessage)); + ChatResponse response = this.chatModel.call(prompt); + assertThat(response.getResults()).hasSize(1); + assertThat(response.getResults().get(0).getOutput().getText()).contains("Blackbeard"); + } + + @Test + void listOutputConverter() { + DefaultConversionService conversionService = new DefaultConversionService(); + ListOutputConverter outputConverter = new ListOutputConverter(conversionService); + + String format = outputConverter.getFormat(); + String template = """ + List five {subject} + {format} + """; + PromptTemplate promptTemplate = PromptTemplate.builder() + .template(template) + .variables(Map.of("subject", "ice cream flavors", "format", format)) + .build(); + Prompt prompt = new Prompt(promptTemplate.createMessage()); + Generation generation = this.chatModel.call(prompt).getResult(); + + List list = outputConverter.convert(generation.getOutput().getText()); + assertThat(list).hasSize(5); + } + + @Test + void mapOutputConverter() { + MapOutputConverter outputConverter = new MapOutputConverter(); + + String format = outputConverter.getFormat(); + String template = """ + Provide me a List of {subject} + {format} + """; + PromptTemplate promptTemplate = PromptTemplate.builder() + .template(template) + .variables(Map.of("subject", "an array of numbers from 1 to 9 under they key name 'numbers'", "format", + format)) + .build(); + Prompt prompt = new Prompt(promptTemplate.createMessage()); + Generation generation = this.chatModel.call(prompt).getResult(); + + Map result = outputConverter.convert(generation.getOutput().getText()); + assertThat(result.get("numbers")).isEqualTo(Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9)); + + } + + @Test + void beanOutputConverterRecords() { + + BeanOutputConverter outputConverter = new BeanOutputConverter<>(ActorsFilmsRecord.class); + + String format = outputConverter.getFormat(); + String template = """ + Generate the filmography of 5 movies for Tom Hanks. + {format} + """; + PromptTemplate promptTemplate = PromptTemplate.builder() + .template(template) + .variables(Map.of("format", format)) + .build(); + Prompt prompt = new Prompt(promptTemplate.createMessage()); + Generation generation = this.chatModel.call(prompt).getResult(); + + ActorsFilmsRecord actorsFilms = outputConverter.convert(generation.getOutput().getText()); + logger.info("{}", actorsFilms); + assertThat(actorsFilms.actor()).isEqualTo("Tom Hanks"); + assertThat(actorsFilms.movies()).hasSize(5); + } + + @Test + void beanStreamOutputConverterRecords() { + + BeanOutputConverter outputConverter = new BeanOutputConverter<>(ActorsFilmsRecord.class); + + String format = outputConverter.getFormat(); + String template = """ + Generate the filmography of 5 movies for Tom Hanks. + {format} + """; + PromptTemplate promptTemplate = PromptTemplate.builder() + .template(template) + .variables(Map.of("format", format)) + .build(); + Prompt prompt = new Prompt(promptTemplate.createMessage()); + + String generationTextFromStream = this.streamingChatModel.stream(prompt) + .collectList() + .block() + .stream() + .map(ChatResponse::getResults) + .flatMap(List::stream) + .map(Generation::getOutput) + .map(AssistantMessage::getText) + .collect(Collectors.joining()); + + ActorsFilmsRecord actorsFilms = outputConverter.convert(generationTextFromStream); + logger.info("" + actorsFilms); + assertThat(actorsFilms.actor()).isEqualTo("Tom Hanks"); + assertThat(actorsFilms.movies()).hasSize(5); + } + + @Test + void functionCallTest() { + + UserMessage userMessage = new UserMessage( + "What's the weather like in San Francisco, Tokyo, and Paris? Response in Celsius"); + + List messages = new ArrayList<>(List.of(userMessage)); + + var promptOptions = CohereChatOptions.builder() + .model(CohereApi.ChatModel.COMMAND_A_R7B.getValue()) + .toolCallbacks(List.of(FunctionToolCallback.builder("getCurrentWeather", new MockWeatherService()) + .description("Get the weather in location") + .inputType(MockWeatherService.Request.class) + .build())) + .build(); + + ChatResponse response = this.chatModel.call(new Prompt(messages, promptOptions)); + + logger.info("Response: {}", response); + + assertThat(response.getResult().getOutput().getText()).containsAnyOf("30.0", "30"); + assertThat(response.getMetadata()).isNotNull(); + assertThat(response.getMetadata().getUsage()).isNotNull(); + } + + @Test + void streamFunctionCallTest() { + + UserMessage userMessage = new UserMessage("What's the weather like in Tokyo, Japan? Response in Celsius"); + + List messages = new ArrayList<>(List.of(userMessage)); + + var promptOptions = CohereChatOptions.builder() + .model(CohereApi.ChatModel.COMMAND_A_R7B.getValue()) + .toolCallbacks(List.of(FunctionToolCallback.builder("getCurrentWeather", new MockWeatherService()) + .description("Get the weather in location") + .inputType(MockWeatherService.Request.class) + .build())) + .build(); + + Flux response = this.streamingChatModel.stream(new Prompt(messages, promptOptions)); + + String content = response.collectList() + .block() + .stream() + .map(ChatResponse::getResults) + .flatMap(List::stream) + .map(Generation::getOutput) + .map(AssistantMessage::getText) + .collect(Collectors.joining()); + logger.info("Response: {}", content); + + assertThat(content).containsAnyOf("10.0", "10"); + } + + @Test + void streamFunctionCallUsageTest() { + UserMessage userMessage = new UserMessage( + "What's the weather like in San Francisco, Tokyo, and Paris? Response in Celsius"); + + List messages = new ArrayList<>(List.of(userMessage)); + + var promptOptions = CohereChatOptions.builder() + .model(CohereApi.ChatModel.COMMAND_A_R7B.getValue()) + .toolCallbacks(List.of(FunctionToolCallback.builder("getCurrentWeather", new MockWeatherService()) + .description("Get the weather in location") + .inputType(MockWeatherService.Request.class) + .build())) + .build(); + + Flux response = this.streamingChatModel.stream(new Prompt(messages, promptOptions)); + ChatResponse chatResponse = response.last().block(); + + logger.info("Response: {}", chatResponse); + assertThat(chatResponse.getMetadata()).isNotNull(); + assertThat(chatResponse.getMetadata().getUsage()).isNotNull(); + } + + @Test + void chatMemory() { + ChatMemory memory = MessageWindowChatMemory.builder().build(); + String conversationId = UUID.randomUUID().toString(); + + UserMessage userMessage1 = new UserMessage("My name is James Bond"); + memory.add(conversationId, userMessage1); + ChatResponse response1 = this.chatModel.call(new Prompt(memory.get(conversationId))); + + assertThat(response1).isNotNull(); + memory.add(conversationId, response1.getResult().getOutput()); + + UserMessage userMessage2 = new UserMessage("What is my name?"); + memory.add(conversationId, userMessage2); + ChatResponse response2 = this.chatModel.call(new Prompt(memory.get(conversationId))); + + assertThat(response2).isNotNull(); + memory.add(conversationId, response2.getResult().getOutput()); + + assertThat(response2.getResults()).hasSize(1); + assertThat(response2.getResult().getOutput().getText()).contains("James Bond"); + } + + @Test + void chatMemoryWithTools() { + ToolCallingManager toolCallingManager = DefaultToolCallingManager.builder().build(); + ChatMemory chatMemory = MessageWindowChatMemory.builder().build(); + String conversationId = UUID.randomUUID().toString(); + + ChatOptions chatOptions = ToolCallingChatOptions.builder() + .toolCallbacks(ToolCallbacks.from(new MathTools())) + .internalToolExecutionEnabled(false) + .build(); + Prompt prompt = new Prompt( + List.of(new SystemMessage("You are a helpful assistant."), new UserMessage("What is 6 * 8?")), + chatOptions); + chatMemory.add(conversationId, prompt.getInstructions()); + + Prompt promptWithMemory = new Prompt(chatMemory.get(conversationId), chatOptions); + ChatResponse chatResponse = this.chatModel.call(promptWithMemory); + chatMemory.add(conversationId, chatResponse.getResult().getOutput()); + + while (chatResponse.hasToolCalls()) { + ToolExecutionResult toolExecutionResult = toolCallingManager.executeToolCalls(promptWithMemory, + chatResponse); + chatMemory.add(conversationId, toolExecutionResult.conversationHistory() + .get(toolExecutionResult.conversationHistory().size() - 1)); + promptWithMemory = new Prompt(chatMemory.get(conversationId), chatOptions); + chatResponse = this.chatModel.call(promptWithMemory); + chatMemory.add(conversationId, chatResponse.getResult().getOutput()); + } + + assertThat(chatResponse).isNotNull(); + assertThat(chatResponse.getResult().getOutput().getText()).contains("48"); + + UserMessage newUserMessage = new UserMessage("What did I ask you earlier?"); + chatMemory.add(conversationId, newUserMessage); + + ChatResponse newResponse = this.chatModel.call(new Prompt(chatMemory.get(conversationId))); + + assertThat(newResponse).isNotNull(); + assertThat(newResponse.getResult().getOutput().getText()).contains("6").contains("8"); + } + + static class MathTools { + + @Tool(description = "Multiply the two numbers") + double multiply(double a, double b) { + return a * b; + } + + } + + record ActorsFilmsRecord(String actor, List movies) { + + } + +} diff --git a/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/chat/CohereChatModelObservationIT.java b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/chat/CohereChatModelObservationIT.java new file mode 100644 index 00000000000..a4e71147c0d --- /dev/null +++ b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/chat/CohereChatModelObservationIT.java @@ -0,0 +1,184 @@ +/* + * Copyright 2023-2025 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.cohere.chat; + +import io.micrometer.common.KeyValue; +import io.micrometer.observation.tck.TestObservationRegistry; +import io.micrometer.observation.tck.TestObservationRegistryAssert; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.ai.chat.metadata.ChatResponseMetadata; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.observation.DefaultChatModelObservationConvention; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.cohere.api.CohereApi; +import org.springframework.ai.observation.conventions.AiOperationType; +import org.springframework.ai.observation.conventions.AiProvider; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Bean; +import org.springframework.retry.support.RetryTemplate; +import org.springframework.util.StringUtils; +import reactor.core.publisher.Flux; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.ai.chat.observation.ChatModelObservationDocumentation.HighCardinalityKeyNames; +import static org.springframework.ai.chat.observation.ChatModelObservationDocumentation.LowCardinalityKeyNames; + +/** + * Integration tests for observation instrumentation in {@link CohereChatModel}. + * + * @author Ricken Bazolo + */ +@SpringBootTest(classes = CohereChatModelObservationIT.Config.class) +@EnabledIfEnvironmentVariable(named = "COHERE_API_KEY", matches = ".+") +public class CohereChatModelObservationIT { + + @Autowired + TestObservationRegistry observationRegistry; + + @Autowired + CohereChatModel chatModel; + + @BeforeEach + void beforeEach() { + this.observationRegistry.clear(); + } + + @Test + void observationForChatOperation() { + var options = CohereChatOptions.builder() + .model(CohereApi.ChatModel.COMMAND_A_R7B.getValue()) + .maxTokens(2048) + .stop(List.of("this-is-the-end")) + .temperature(0.7) + .topP(1.0) + .build(); + + Prompt prompt = new Prompt("Why does a raven look like a desk?", options); + + ChatResponse chatResponse = this.chatModel.call(prompt); + assertThat(chatResponse.getResult().getOutput().getText()).isNotEmpty(); + + ChatResponseMetadata responseMetadata = chatResponse.getMetadata(); + assertThat(responseMetadata).isNotNull(); + + validate(responseMetadata); + } + + @Test + void observationForStreamingChatOperation() { + var options = CohereChatOptions.builder() + .model(CohereApi.ChatModel.COMMAND_A_R7B.getValue()) + .maxTokens(2048) + .stop(List.of("this-is-the-end")) + .temperature(0.7) + .topP(1.0) + .build(); + + Prompt prompt = new Prompt("Why does a raven look like a desk?", options); + + Flux chatResponseFlux = this.chatModel.stream(prompt); + + List responses = chatResponseFlux.collectList().block(); + assertThat(responses).isNotEmpty(); + + // With MessageAggregator, all chunks are aggregated into a single response + // So we get the aggregated text from the last (or only) response + ChatResponse lastChatResponse = responses.get(responses.size() - 1); + String aggregatedResponse = lastChatResponse.getResult().getOutput().getText(); + assertThat(aggregatedResponse).isNotEmpty(); + + ChatResponseMetadata responseMetadata = lastChatResponse.getMetadata(); + assertThat(responseMetadata).isNotNull(); + + validate(responseMetadata); + } + + private void validate(ChatResponseMetadata responseMetadata) { + TestObservationRegistryAssert.assertThat(this.observationRegistry) + .doesNotHaveAnyRemainingCurrentObservation() + .hasObservationWithNameEqualTo(DefaultChatModelObservationConvention.DEFAULT_NAME) + .that() + .hasContextualNameEqualTo("chat " + CohereApi.ChatModel.COMMAND_A_R7B.getValue()) + .hasLowCardinalityKeyValue(LowCardinalityKeyNames.AI_OPERATION_TYPE.asString(), + AiOperationType.CHAT.value()) + .hasLowCardinalityKeyValue(LowCardinalityKeyNames.AI_PROVIDER.asString(), AiProvider.COHERE.value()) + .hasLowCardinalityKeyValue(LowCardinalityKeyNames.REQUEST_MODEL.asString(), + CohereApi.ChatModel.COMMAND_A_R7B.getValue()) + .hasLowCardinalityKeyValue(LowCardinalityKeyNames.RESPONSE_MODEL.asString(), + StringUtils.hasText(responseMetadata.getModel()) ? responseMetadata.getModel() + : KeyValue.NONE_VALUE) + .hasHighCardinalityKeyValue(HighCardinalityKeyNames.REQUEST_MAX_TOKENS.asString(), "2048") + .hasHighCardinalityKeyValue(HighCardinalityKeyNames.REQUEST_STOP_SEQUENCES.asString(), + "[\"this-is-the-end\"]") + .hasHighCardinalityKeyValue(HighCardinalityKeyNames.REQUEST_TEMPERATURE.asString(), "0.7") + .doesNotHaveHighCardinalityKeyValueWithKey(HighCardinalityKeyNames.REQUEST_TOP_K.asString()) + .hasHighCardinalityKeyValue(HighCardinalityKeyNames.REQUEST_TOP_P.asString(), "1.0") + .matches(contextView -> { + var keyValue = contextView.getHighCardinalityKeyValues() + .stream() + .filter(tag -> tag.getKey().equals(HighCardinalityKeyNames.RESPONSE_ID.asString())) + .findFirst(); + if (StringUtils.hasText(responseMetadata.getId())) { + return keyValue.isPresent() && keyValue.get().getValue().equals(responseMetadata.getId()); + } + else { + return keyValue.isEmpty(); + } + }) + .hasHighCardinalityKeyValue(HighCardinalityKeyNames.RESPONSE_FINISH_REASONS.asString(), "[\"COMPLETE\"]") + .hasHighCardinalityKeyValue(HighCardinalityKeyNames.USAGE_INPUT_TOKENS.asString(), + String.valueOf(responseMetadata.getUsage().getPromptTokens())) + .hasHighCardinalityKeyValue(HighCardinalityKeyNames.USAGE_OUTPUT_TOKENS.asString(), + String.valueOf(responseMetadata.getUsage().getCompletionTokens())) + .hasHighCardinalityKeyValue(HighCardinalityKeyNames.USAGE_TOTAL_TOKENS.asString(), + String.valueOf(responseMetadata.getUsage().getTotalTokens())) + .hasBeenStarted() + .hasBeenStopped(); + } + + @SpringBootConfiguration + static class Config { + + @Bean + public TestObservationRegistry observationRegistry() { + return TestObservationRegistry.create(); + } + + @Bean + public CohereApi cohereApi() { + return CohereApi.builder().apiKey(System.getenv("COHERE_API_KEY")).build(); + } + + @Bean + public CohereChatModel cohereChatModel(CohereApi cohereApi, TestObservationRegistry observationRegistry) { + return CohereChatModel.builder() + .cohereApi(cohereApi) + .defaultOptions(CohereChatOptions.builder().build()) + .retryTemplate(RetryTemplate.defaultInstance()) + .observationRegistry(observationRegistry) + .build(); + } + + } + +} diff --git a/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/chat/CohereChatOptionsTests.java b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/chat/CohereChatOptionsTests.java new file mode 100644 index 00000000000..fd0a202fec7 --- /dev/null +++ b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/chat/CohereChatOptionsTests.java @@ -0,0 +1,248 @@ +/* + * Copyright 2025-2025 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.cohere.chat; + +import org.junit.jupiter.api.Test; +import org.springframework.ai.cohere.api.CohereApi; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link CohereChatOptions}. + * + * @author Ricken Bazolo + */ +class CohereChatOptionsTests { + + @Test + void testBuilderWithAllFields() { + CohereChatOptions options = CohereChatOptions.builder() + .model("test-model") + .temperature(0.7) + .topP(0.9) + .maxTokens(100) + .seed(123) + .stop(List.of("stop1", "stop2")) + .toolChoice(CohereApi.ChatCompletionRequest.ToolChoice.REQUIRED) + .internalToolExecutionEnabled(true) + .toolContext(Map.of("key1", "value1")) + .build(); + + assertThat(options) + .extracting("model", "temperature", "topP", "maxTokens", "seed", "stop", "toolChoice", + "internalToolExecutionEnabled", "toolContext") + .containsExactly("test-model", 0.7, 0.9, 100, 123, List.of("stop1", "stop2"), + CohereApi.ChatCompletionRequest.ToolChoice.REQUIRED, true, Map.of("key1", "value1")); + } + + @Test + void testBuilderWithEnum() { + CohereChatOptions optionsWithEnum = CohereChatOptions.builder() + .model(CohereApi.ChatModel.COMMAND_A_R7B.getValue()) + .build(); + assertThat(optionsWithEnum.getModel()).isEqualTo(CohereApi.ChatModel.COMMAND_A_R7B.getValue()); + } + + @Test + void testCopy() { + CohereChatOptions options = CohereChatOptions.builder() + .model("test-model") + .temperature(0.7) + .topP(0.9) + .maxTokens(100) + .seed(123) + .stop(List.of("stop1", "stop2")) + .internalToolExecutionEnabled(true) + .toolContext(Map.of("key1", "value1")) + .build(); + + CohereChatOptions copiedOptions = options.copy(); + assertThat(copiedOptions).isNotSameAs(options).isEqualTo(options); + // Ensure deep copy + assertThat(copiedOptions.getStop()).isNotSameAs(options.getStop()); + assertThat(copiedOptions.getToolContext()).isNotSameAs(options.getToolContext()); + } + + @Test + void testSetters() { + CohereChatOptions options = new CohereChatOptions(); + options.setModel("test-model"); + options.setTemperature(0.7); + options.setTopP(0.9); + options.setMaxTokens(100); + options.setSeed(123); + options.setStopSequences(List.of("stop1", "stop2")); + + assertThat(options.getModel()).isEqualTo("test-model"); + assertThat(options.getTemperature()).isEqualTo(0.7); + assertThat(options.getTopP()).isEqualTo(0.9); + assertThat(options.getMaxTokens()).isEqualTo(100); + assertThat(options.getSeed()).isEqualTo(123); + assertThat(options.getStopSequences()).isEqualTo(List.of("stop1", "stop2")); + } + + @Test + void testDefaultValues() { + CohereChatOptions options = new CohereChatOptions(); + assertThat(options.getModel()).isNull(); + assertThat(options.getTemperature()).isNull(); + assertThat(options.getTopP()).isNull(); + assertThat(options.getMaxTokens()).isNull(); + assertThat(options.getSeed()).isNull(); + assertThat(options.getStopSequences()).isNull(); + } + + @Test + void testBuilderWithEmptyCollections() { + CohereChatOptions options = CohereChatOptions.builder() + .stop(Collections.emptyList()) + .toolContext(Collections.emptyMap()) + .build(); + + assertThat(options.getStop()).isEmpty(); + assertThat(options.getToolContext()).isEmpty(); + } + + @Test + void testBuilderWithBoundaryValues() { + CohereChatOptions options = CohereChatOptions.builder() + .temperature(0.0) + .topP(1.0) + .maxTokens(1) + .seed(Integer.MAX_VALUE) + .build(); + + assertThat(options.getTemperature()).isEqualTo(0.0); + assertThat(options.getTopP()).isEqualTo(1.0); + assertThat(options.getMaxTokens()).isEqualTo(1); + assertThat(options.getSeed()).isEqualTo(Integer.MAX_VALUE); + } + + @Test + void testBuilderWithSingleElementCollections() { + CohereChatOptions options = CohereChatOptions.builder() + .stop(List.of("single-stop")) + .toolContext(Map.of("single-key", "single-value")) + .build(); + + assertThat(options.getStop()).hasSize(1).containsExactly("single-stop"); + assertThat(options.getToolContext()).hasSize(1).containsEntry("single-key", "single-value"); + } + + @Test + void testCopyWithEmptyOptions() { + CohereChatOptions emptyOptions = new CohereChatOptions(); + CohereChatOptions copiedOptions = emptyOptions.copy(); + + assertThat(copiedOptions).isNotSameAs(emptyOptions).isEqualTo(emptyOptions); + assertThat(copiedOptions.getModel()).isNull(); + assertThat(copiedOptions.getTemperature()).isNull(); + } + + @Test + void testCopyMutationDoesNotAffectOriginal() { + CohereChatOptions original = CohereChatOptions.builder() + .model("original-model") + .temperature(0.5) + .stop(List.of("original-stop")) + .toolContext(Map.of("original", "value")) + .build(); + + CohereChatOptions copy = original.copy(); + copy.setModel("modified-model"); + copy.setTemperature(0.8); + + // Original should remain unchanged + assertThat(original.getModel()).isEqualTo("original-model"); + assertThat(original.getTemperature()).isEqualTo(0.5); + + // Copy should have new values + assertThat(copy.getModel()).isEqualTo("modified-model"); + assertThat(copy.getTemperature()).isEqualTo(0.8); + } + + @Test + void testEqualsAndHashCode() { + CohereChatOptions options1 = CohereChatOptions.builder().model("test-model").temperature(0.7).build(); + + CohereChatOptions options2 = CohereChatOptions.builder().model("test-model").temperature(0.7).build(); + + CohereChatOptions options3 = CohereChatOptions.builder().model("different-model").temperature(0.7).build(); + + assertThat(options1).isEqualTo(options2); + assertThat(options1.hashCode()).isEqualTo(options2.hashCode()); + + assertThat(options1).isNotEqualTo(options3); + assertThat(options1.hashCode()).isNotEqualTo(options3.hashCode()); + } + + @Test + void testAllToolChoiceEnumValues() { + for (CohereApi.ChatCompletionRequest.ToolChoice toolChoice : CohereApi.ChatCompletionRequest.ToolChoice + .values()) { + + CohereChatOptions options = CohereChatOptions.builder().toolChoice(toolChoice).build(); + + assertThat(options.getToolChoice()).isEqualTo(toolChoice); + } + } + + @Test + void testChainedBuilderMethods() { + CohereChatOptions options = CohereChatOptions.builder() + .model("test-model") + .temperature(0.7) + .topP(0.9) + .maxTokens(100) + .seed(123) + .internalToolExecutionEnabled(false) + .build(); + + // Verify all chained methods worked + assertThat(options.getModel()).isEqualTo("test-model"); + assertThat(options.getTemperature()).isEqualTo(0.7); + assertThat(options.getTopP()).isEqualTo(0.9); + assertThat(options.getMaxTokens()).isEqualTo(100); + assertThat(options.getSeed()).isEqualTo(123); + assertThat(options.getInternalToolExecutionEnabled()).isFalse(); + } + + @Test + void testBuilderAndSetterConsistency() { + // Build an object using builder + CohereChatOptions builderOptions = CohereChatOptions.builder() + .model("test-model") + .temperature(0.7) + .topP(0.9) + .maxTokens(100) + .build(); + + // Create equivalent object using setters + CohereChatOptions setterOptions = new CohereChatOptions(); + setterOptions.setModel("test-model"); + setterOptions.setTemperature(0.7); + setterOptions.setTopP(0.9); + setterOptions.setMaxTokens(100); + + assertThat(builderOptions).isEqualTo(setterOptions); + } + +} diff --git a/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/embedding/CohereEmbeddingIT.java b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/embedding/CohereEmbeddingIT.java new file mode 100644 index 00000000000..d1e46f97da4 --- /dev/null +++ b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/embedding/CohereEmbeddingIT.java @@ -0,0 +1,89 @@ +/* + * Copyright 2023-2025 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.cohere.embedding; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.springframework.ai.cohere.CohereTestConfiguration; +import org.springframework.ai.cohere.api.CohereApi; +import org.springframework.ai.embedding.EmbeddingRequest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Ricken Bazolo + */ +@SpringBootTest(classes = CohereTestConfiguration.class) +@EnabledIfEnvironmentVariable(named = "COHERE_API_KEY", matches = ".+") +class CohereEmbeddingIT { + + private static final int EMBED_DIMENSIONS = 384; + + @Autowired + private CohereApi cohereApi; + + @Autowired + private CohereEmbeddingModel cohereEmbeddingModel; + + @Test + void defaultEmbedding() { + var embeddingResponse = this.cohereEmbeddingModel.embedForResponse(List.of("Hello World")); + assertThat(embeddingResponse.getResults()).hasSize(1); + assertThat(embeddingResponse.getResults().get(0)).isNotNull(); + assertThat(embeddingResponse.getResults().get(0).getOutput()).hasSize(EMBED_DIMENSIONS); + assertThat(this.cohereEmbeddingModel.dimensions()).isEqualTo(EMBED_DIMENSIONS); + } + + @ParameterizedTest + @CsvSource({ "embed-multilingual-light-v3.0, 384", "embed-english-light-v3.0, 384" }) + void defaultOptionsEmbedding(String model, int dimensions) { + var cohereEmbeddingOptions = CohereEmbeddingOptions.builder().model(model).build(); + var anotherCohereEmbeddingModel = CohereEmbeddingModel.builder() + .cohereApi(this.cohereApi) + .options(cohereEmbeddingOptions) + .build(); + var embeddingResponse = anotherCohereEmbeddingModel.embedForResponse(List.of("Hello World", "World is big")); + assertThat(embeddingResponse.getResults()).hasSize(2); + embeddingResponse.getResults().forEach(result -> { + assertThat(result).isNotNull(); + assertThat(result.getOutput()).hasSize(dimensions); + }); + assertThat(anotherCohereEmbeddingModel.dimensions()).isEqualTo(dimensions); + } + + @ParameterizedTest + @CsvSource({ "embed-multilingual-light-v3.0, 384", "embed-english-light-v3.0, 384" }) + void calledOptionsEmbedding(String model, int dimensions) { + var cohereEmbeddingOptions = CohereEmbeddingOptions.builder().model(model).build(); + var embeddingRequest = new EmbeddingRequest(List.of("Hello World", "World is big", "We are small"), + cohereEmbeddingOptions); + var embeddingResponse = this.cohereEmbeddingModel.call(embeddingRequest); + assertThat(embeddingResponse.getResults()).hasSize(3); + embeddingResponse.getResults().forEach(result -> { + assertThat(result).isNotNull(); + assertThat(result.getOutput()).hasSize(dimensions); + }); + assertThat(this.cohereEmbeddingModel.dimensions()).isEqualTo(EMBED_DIMENSIONS); + } + +} diff --git a/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/embedding/CohereEmbeddingModelObservationIT.java b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/embedding/CohereEmbeddingModelObservationIT.java new file mode 100644 index 00000000000..d062374c7be --- /dev/null +++ b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/embedding/CohereEmbeddingModelObservationIT.java @@ -0,0 +1,117 @@ +/* + * Copyright 2023-2025 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.cohere.embedding; + +import io.micrometer.observation.tck.TestObservationRegistry; +import io.micrometer.observation.tck.TestObservationRegistryAssert; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.ai.embedding.EmbeddingRequest; +import org.springframework.ai.embedding.EmbeddingResponse; +import org.springframework.ai.embedding.EmbeddingResponseMetadata; +import org.springframework.ai.embedding.observation.DefaultEmbeddingModelObservationConvention; +import org.springframework.ai.cohere.api.CohereApi; +import org.springframework.ai.observation.conventions.AiOperationType; +import org.springframework.ai.observation.conventions.AiProvider; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Bean; +import org.springframework.retry.support.RetryTemplate; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.ai.embedding.observation.EmbeddingModelObservationDocumentation.HighCardinalityKeyNames; +import static org.springframework.ai.embedding.observation.EmbeddingModelObservationDocumentation.LowCardinalityKeyNames; + +/** + * Integration tests for observation instrumentation in {@link CohereEmbeddingModel}. + * + * @author Ricken Bazolo + */ +@SpringBootTest(classes = CohereEmbeddingModelObservationIT.Config.class) +@EnabledIfEnvironmentVariable(named = "COHERE_API_KEY", matches = ".+") +public class CohereEmbeddingModelObservationIT { + + @Autowired + TestObservationRegistry observationRegistry; + + @Autowired + CohereEmbeddingModel embeddingModel; + + @Test + void observationForEmbeddingOperation() { + var options = CohereEmbeddingOptions.builder() + .model(CohereApi.EmbeddingModel.EMBED_MULTILINGUAL_V3.getValue()) + .build(); + + EmbeddingRequest embeddingRequest = new EmbeddingRequest(List.of("Here comes the sun"), options); + + EmbeddingResponse embeddingResponse = this.embeddingModel.call(embeddingRequest); + assertThat(embeddingResponse.getResults()).isNotEmpty(); + + EmbeddingResponseMetadata responseMetadata = embeddingResponse.getMetadata(); + assertThat(responseMetadata).isNotNull(); + + TestObservationRegistryAssert.assertThat(this.observationRegistry) + .doesNotHaveAnyRemainingCurrentObservation() + .hasObservationWithNameEqualTo(DefaultEmbeddingModelObservationConvention.DEFAULT_NAME) + .that() + .hasContextualNameEqualTo("embedding " + CohereApi.EmbeddingModel.EMBED_MULTILINGUAL_V3.getValue()) + .hasLowCardinalityKeyValue(LowCardinalityKeyNames.AI_OPERATION_TYPE.asString(), + AiOperationType.EMBEDDING.value()) + .hasLowCardinalityKeyValue(LowCardinalityKeyNames.AI_PROVIDER.asString(), AiProvider.COHERE.value()) + .hasLowCardinalityKeyValue(LowCardinalityKeyNames.REQUEST_MODEL.asString(), + CohereApi.EmbeddingModel.EMBED_MULTILINGUAL_V3.getValue()) + .hasLowCardinalityKeyValue(LowCardinalityKeyNames.RESPONSE_MODEL.asString(), responseMetadata.getModel()) + .doesNotHaveHighCardinalityKeyValueWithKey(HighCardinalityKeyNames.REQUEST_EMBEDDING_DIMENSIONS.asString()) + .hasHighCardinalityKeyValue(HighCardinalityKeyNames.USAGE_INPUT_TOKENS.asString(), + String.valueOf(responseMetadata.getUsage().getPromptTokens())) + .hasHighCardinalityKeyValue(HighCardinalityKeyNames.USAGE_TOTAL_TOKENS.asString(), + String.valueOf(responseMetadata.getUsage().getTotalTokens())) + .hasBeenStarted() + .hasBeenStopped(); + } + + @SpringBootConfiguration + static class Config { + + @Bean + public TestObservationRegistry observationRegistry() { + return TestObservationRegistry.create(); + } + + @Bean + public CohereApi cohereApi() { + return CohereApi.builder().apiKey(System.getenv("COHERE_API_KEY")).build(); + } + + @Bean + public CohereEmbeddingModel cohereEmbeddingModel(CohereApi cohereApi, + TestObservationRegistry observationRegistry) { + return CohereEmbeddingModel.builder() + .cohereApi(cohereApi) + .options(CohereEmbeddingOptions.builder().build()) + .retryTemplate(RetryTemplate.defaultInstance()) + .observationRegistry(observationRegistry) + .build(); + } + + } + +} diff --git a/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/embedding/CohereEmbeddingModelTests.java b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/embedding/CohereEmbeddingModelTests.java new file mode 100644 index 00000000000..9a9ab9e308e --- /dev/null +++ b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/embedding/CohereEmbeddingModelTests.java @@ -0,0 +1,154 @@ +/* + * Copyright 2025-2025 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.cohere.embedding; + +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.ai.cohere.api.CohereApi; +import org.springframework.ai.document.MetadataMode; +import org.springframework.ai.retry.RetryUtils; +import org.springframework.http.ResponseEntity; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link CohereEmbeddingModel}. + * + * @author Ricken Bazolo + */ +class CohereEmbeddingModelTests { + + @Test + void testDimensionsForEmbedV4Model() { + CohereApi mockApi = createMockApiWithEmbeddingResponse(1024); + + CohereEmbeddingOptions options = CohereEmbeddingOptions.builder() + .model(CohereApi.EmbeddingModel.EMBED_V4.getValue()) + .build(); + + CohereEmbeddingModel model = CohereEmbeddingModel.builder() + .cohereApi(mockApi) + .metadataMode(MetadataMode.EMBED) + .options(options) + .retryTemplate(RetryUtils.DEFAULT_RETRY_TEMPLATE) + .build(); + + assertThat(model.dimensions()).isEqualTo(1536); + } + + @Test + void testDimensionsForMultilingualV3Model() { + CohereApi mockApi = createMockApiWithEmbeddingResponse(1024); + + CohereEmbeddingOptions options = CohereEmbeddingOptions.builder() + .model(CohereApi.EmbeddingModel.EMBED_MULTILINGUAL_V3.getValue()) + .build(); + + CohereEmbeddingModel model = CohereEmbeddingModel.builder() + .cohereApi(mockApi) + .metadataMode(MetadataMode.EMBED) + .options(options) + .retryTemplate(RetryUtils.DEFAULT_RETRY_TEMPLATE) + .build(); + + assertThat(model.dimensions()).isEqualTo(1024); + } + + @Test + void testDimensionsFallbackForUnknownModel() { + CohereApi mockApi = createMockApiWithEmbeddingResponse(512); + + // Use a model name that doesn't exist in KNOWN_EMBEDDING_DIMENSIONS + CohereEmbeddingOptions options = CohereEmbeddingOptions.builder().model("unknown-model").build(); + + CohereEmbeddingModel model = CohereEmbeddingModel.builder() + .cohereApi(mockApi) + .metadataMode(MetadataMode.EMBED) + .options(options) + .retryTemplate(RetryUtils.DEFAULT_RETRY_TEMPLATE) + .build(); + + // Should fall back to super.dimensions() which detects dimensions from the API + // response + assertThat(model.dimensions()).isEqualTo(1024); + } + + @Test + void testAllEmbeddingModelsHaveDimensionMapping() { + // This test ensures that KNOWN_EMBEDDING_DIMENSIONS map stays in sync with the + // EmbeddingModel enum + // If a new model is added to the enum but not to the dimensions map, this test + // will help catch it + + for (CohereApi.EmbeddingModel embeddingModel : CohereApi.EmbeddingModel.values()) { + CohereApi mockApi = createMockApiWithEmbeddingResponse(1024); + CohereEmbeddingOptions options = CohereEmbeddingOptions.builder().model(embeddingModel.getValue()).build(); + + CohereEmbeddingModel model = CohereEmbeddingModel.builder() + .cohereApi(mockApi) + .metadataMode(MetadataMode.EMBED) + .options(options) + .retryTemplate(RetryUtils.DEFAULT_RETRY_TEMPLATE) + .build(); + + // Each model should have a valid dimension (not the fallback -1) + assertThat(model.dimensions()).as("Model %s should have a dimension mapping", embeddingModel.getValue()) + .isGreaterThan(0); + } + } + + @Test + void testBuilderCreatesValidModel() { + CohereApi mockApi = createMockApiWithEmbeddingResponse(1024); + + CohereEmbeddingModel model = CohereEmbeddingModel.builder() + .cohereApi(mockApi) + .options(CohereEmbeddingOptions.builder() + .model(CohereApi.EmbeddingModel.EMBED_MULTILINGUAL_V3.getValue()) + .build()) + .build(); + + assertThat(model).isNotNull(); + assertThat(model.dimensions()).isEqualTo(1024); + } + + private CohereApi createMockApiWithEmbeddingResponse(int dimensions) { + CohereApi mockApi = Mockito.mock(CohereApi.class); + + // Create a mock embedding response with the specified dimensions + // Cohere returns List> for embeddings + List embedding = new java.util.ArrayList<>(dimensions); + for (int i = 0; i < dimensions; i++) { + embedding.add(0.1); + } + + // Cohere can return embeddings for multiple texts + List> embeddings = List.of(embedding); + + CohereApi.EmbeddingResponse embeddingResponse = new CohereApi.EmbeddingResponse("test-id", embeddings, + List.of("test text"), "embeddings_floats"); + + when(mockApi.embeddings(any())).thenReturn(ResponseEntity.ok(embeddingResponse)); + + return mockApi; + } + +} From 8054289e93d69ba38003f63d27c7786b4256ce19 Mon Sep 17 00:00:00 2001 From: Ricken BAZOLO Date: Sat, 22 Nov 2025 03:46:41 +0100 Subject: [PATCH 14/18] added cohere support :: resolved checkstyle violations Signed-off-by: Ricken Bazolo Signed-off-by: ricken07 --- .../pom.xml | 16 +- .../CohereChatAutoConfiguration.java | 28 +- .../autoconfigure/CohereChatProperties.java | 16 + .../autoconfigure/CohereCommonProperties.java | 16 + .../CohereEmbeddingAutoConfiguration.java | 5 +- .../CohereEmbeddingProperties.java | 7 +- .../autoconfigure/CohereParentProperties.java | 16 + .../CohereAutoConfigurationIT.java | 41 ++- .../CohereModelConfigurationTests.java | 17 ++ .../autoconfigure/CoherePropertiesTests.java | 19 +- .../ai/cohere/aot/CohereRuntimeHints.java | 4 +- .../ai/cohere/api/CohereApi.java | 278 +++++++++--------- .../CohereStreamFunctionCallingHelper.java | 57 ++-- .../ai/cohere/chat/CohereChatModel.java | 51 ++-- .../ai/cohere/chat/CohereChatOptions.java | 57 ++-- .../embedding/CohereEmbeddingModel.java | 30 +- .../embedding/CohereEmbeddingOptions.java | 13 +- .../schema/CohereToolCallingManager.java | 7 +- .../ai/cohere/CohereRetryTests.java | 73 ++--- .../ai/cohere/CohereTestConfiguration.java | 4 +- .../cohere/aot/CohereRuntimeHintsTests.java | 7 +- .../ai/cohere/api/CohereApiIT.java | 30 +- .../api/tool/CohereApiToolFunctionCallIT.java | 7 +- .../cohere/api/tool/MockWeatherService.java | 4 +- .../tool/PaymentStatusFunctionCallingIT.java | 11 +- .../ai/cohere/chat/CohereChatClientIT.java | 17 +- .../CohereChatCompletionRequestTests.java | 19 +- .../ai/cohere/chat/CohereChatModelIT.java | 19 +- .../chat/CohereChatModelObservationIT.java | 11 +- .../cohere/chat/CohereChatOptionsTests.java | 7 +- .../cohere/embedding/CohereEmbeddingIT.java | 5 +- .../CohereEmbeddingModelObservationIT.java | 11 +- .../embedding/CohereEmbeddingModelTests.java | 5 +- .../ai/cohere/testutils/AbstractIT.java | 7 +- 34 files changed, 539 insertions(+), 376 deletions(-) diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-cohere/pom.xml b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/pom.xml index 896d5047923..737dfbb162c 100644 --- a/auto-configurations/models/spring-ai-autoconfigure-model-cohere/pom.xml +++ b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/pom.xml @@ -24,8 +24,6 @@ - - org.springframework.ai spring-ai-cohere @@ -33,8 +31,6 @@ true - - org.springframework.ai spring-ai-autoconfigure-model-tool @@ -53,13 +49,23 @@ ${project.parent.version} - org.springframework.boot spring-boot-starter true + + org.springframework.boot + spring-boot-starter-webclient + true + + + org.springframework.boot + spring-boot-starter-restclient + true + + org.springframework.boot spring-boot-configuration-processor diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/java/org/springframework/ai/cohere/autoconfigure/CohereChatAutoConfiguration.java b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/java/org/springframework/ai/cohere/autoconfigure/CohereChatAutoConfiguration.java index 255123b1807..4fee095d6c9 100644 --- a/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/java/org/springframework/ai/cohere/autoconfigure/CohereChatAutoConfiguration.java +++ b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/java/org/springframework/ai/cohere/autoconfigure/CohereChatAutoConfiguration.java @@ -1,6 +1,23 @@ +/* + * Copyright 2023-2025 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.cohere.autoconfigure; import io.micrometer.observation.ObservationRegistry; + import org.springframework.ai.chat.observation.ChatModelObservationConvention; import org.springframework.ai.cohere.api.CohereApi; import org.springframework.ai.cohere.chat.CohereChatModel; @@ -17,10 +34,11 @@ 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.boot.restclient.autoconfigure.RestClientAutoConfiguration; +import org.springframework.boot.webclient.autoconfigure.WebClientAutoConfiguration; import org.springframework.context.annotation.Bean; -import org.springframework.retry.support.RetryTemplate; +import org.springframework.core.retry.RetryTemplate; import org.springframework.util.Assert; import org.springframework.util.StringUtils; import org.springframework.web.client.ResponseErrorHandler; @@ -32,8 +50,8 @@ * * @author Ricken Bazolo */ -@AutoConfiguration(after = { RestClientAutoConfiguration.class, SpringAiRetryAutoConfiguration.class, - ToolCallingAutoConfiguration.class }) +@AutoConfiguration(after = { RestClientAutoConfiguration.class, WebClientAutoConfiguration.class, + SpringAiRetryAutoConfiguration.class, ToolCallingAutoConfiguration.class }) @EnableConfigurationProperties({ CohereCommonProperties.class, CohereChatProperties.class }) @ConditionalOnProperty(name = SpringAIModelProperties.CHAT_MODEL, havingValue = SpringAIModels.COHERE, matchIfMissing = true) @@ -61,7 +79,7 @@ public CohereChatModel chereChatModel(CohereCommonProperties commonProperties, C .toolCallingManager(toolCallingManager) .toolExecutionEligibilityPredicate( cohereToolExecutionEligibilityPredicate.getIfUnique(DefaultToolExecutionEligibilityPredicate::new)) - .retryTemplate(retryTemplate) + .retryTemplate(new RetryTemplate()) .observationRegistry(observationRegistry.getIfUnique(() -> ObservationRegistry.NOOP)) .build(); diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/java/org/springframework/ai/cohere/autoconfigure/CohereChatProperties.java b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/java/org/springframework/ai/cohere/autoconfigure/CohereChatProperties.java index f0ccfc6882b..49ef712477f 100644 --- a/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/java/org/springframework/ai/cohere/autoconfigure/CohereChatProperties.java +++ b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/java/org/springframework/ai/cohere/autoconfigure/CohereChatProperties.java @@ -1,3 +1,19 @@ +/* + * Copyright 2023-2025 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.cohere.autoconfigure; import org.springframework.ai.cohere.api.CohereApi; diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/java/org/springframework/ai/cohere/autoconfigure/CohereCommonProperties.java b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/java/org/springframework/ai/cohere/autoconfigure/CohereCommonProperties.java index b4596bbc0da..db672b8328b 100644 --- a/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/java/org/springframework/ai/cohere/autoconfigure/CohereCommonProperties.java +++ b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/java/org/springframework/ai/cohere/autoconfigure/CohereCommonProperties.java @@ -1,3 +1,19 @@ +/* + * Copyright 2023-2025 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.cohere.autoconfigure; import org.springframework.boot.context.properties.ConfigurationProperties; diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/java/org/springframework/ai/cohere/autoconfigure/CohereEmbeddingAutoConfiguration.java b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/java/org/springframework/ai/cohere/autoconfigure/CohereEmbeddingAutoConfiguration.java index 0207a23a723..127610a2288 100644 --- a/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/java/org/springframework/ai/cohere/autoconfigure/CohereEmbeddingAutoConfiguration.java +++ b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/java/org/springframework/ai/cohere/autoconfigure/CohereEmbeddingAutoConfiguration.java @@ -17,6 +17,7 @@ package org.springframework.ai.cohere.autoconfigure; import io.micrometer.observation.ObservationRegistry; + import org.springframework.ai.cohere.api.CohereApi; import org.springframework.ai.cohere.embedding.CohereEmbeddingModel; import org.springframework.ai.embedding.observation.EmbeddingModelObservationConvention; @@ -28,10 +29,10 @@ 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.boot.restclient.autoconfigure.RestClientAutoConfiguration; import org.springframework.context.annotation.Bean; -import org.springframework.retry.support.RetryTemplate; +import org.springframework.core.retry.RetryTemplate; import org.springframework.util.Assert; import org.springframework.util.StringUtils; import org.springframework.web.client.ResponseErrorHandler; diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/java/org/springframework/ai/cohere/autoconfigure/CohereEmbeddingProperties.java b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/java/org/springframework/ai/cohere/autoconfigure/CohereEmbeddingProperties.java index 3578ffd7079..75acd6b3f8c 100644 --- a/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/java/org/springframework/ai/cohere/autoconfigure/CohereEmbeddingProperties.java +++ b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/java/org/springframework/ai/cohere/autoconfigure/CohereEmbeddingProperties.java @@ -16,16 +16,15 @@ package org.springframework.ai.cohere.autoconfigure; -import org.springframework.ai.cohere.api.CohereApi; -import org.springframework.ai.cohere.api.CohereApi.EmbeddingType; +import java.util.List; + import org.springframework.ai.cohere.api.CohereApi.EmbeddingModel; +import org.springframework.ai.cohere.api.CohereApi.EmbeddingType; import org.springframework.ai.cohere.embedding.CohereEmbeddingOptions; import org.springframework.ai.document.MetadataMode; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.NestedConfigurationProperty; -import java.util.List; - /** * Configuration properties for Cohere embedding model. * diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/java/org/springframework/ai/cohere/autoconfigure/CohereParentProperties.java b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/java/org/springframework/ai/cohere/autoconfigure/CohereParentProperties.java index 8b1c75d1d38..0561690d7bb 100644 --- a/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/java/org/springframework/ai/cohere/autoconfigure/CohereParentProperties.java +++ b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/java/org/springframework/ai/cohere/autoconfigure/CohereParentProperties.java @@ -1,3 +1,19 @@ +/* + * Copyright 2023-2025 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.cohere.autoconfigure; /** diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/test/java/org/springframework/ai/cohere/autoconfigure/CohereAutoConfigurationIT.java b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/test/java/org/springframework/ai/cohere/autoconfigure/CohereAutoConfigurationIT.java index e5ff88345a8..8999ed59c0a 100644 --- a/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/test/java/org/springframework/ai/cohere/autoconfigure/CohereAutoConfigurationIT.java +++ b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/test/java/org/springframework/ai/cohere/autoconfigure/CohereAutoConfigurationIT.java @@ -1,9 +1,29 @@ +/* + * Copyright 2023-2025 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.cohere.autoconfigure; +import java.util.List; + 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.cohere.chat.CohereChatModel; import org.springframework.ai.cohere.embedding.CohereEmbeddingModel; import org.springframework.ai.embedding.EmbeddingResponse; @@ -11,8 +31,6 @@ import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.test.context.runner.ApplicationContextRunner; -import java.util.List; - import static org.assertj.core.api.Assertions.assertThat; /** @@ -55,4 +73,23 @@ void embedding() { }); } + @Test + void generateStreaming() { + this.contextRunner.withConfiguration(SpringAiTestAutoConfigurations.of(CohereChatAutoConfiguration.class)) + .run(context -> { + CohereChatModel chatModel = context.getBean(CohereChatModel.class); + Flux responseFlux = chatModel + .stream(new org.springframework.ai.chat.prompt.Prompt( + new org.springframework.ai.chat.messages.UserMessage("Hello"))); + String response = responseFlux.collectList() + .block() + .stream() + .map(chatResponse -> chatResponse.getResults().get(0).getOutput().getText()) + .collect(java.util.stream.Collectors.joining()); + + assertThat(response).isNotEmpty(); + logger.info("Response: " + response); + }); + } + } diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/test/java/org/springframework/ai/cohere/autoconfigure/CohereModelConfigurationTests.java b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/test/java/org/springframework/ai/cohere/autoconfigure/CohereModelConfigurationTests.java index 4793b92780e..0d734a8d06d 100644 --- a/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/test/java/org/springframework/ai/cohere/autoconfigure/CohereModelConfigurationTests.java +++ b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/test/java/org/springframework/ai/cohere/autoconfigure/CohereModelConfigurationTests.java @@ -1,6 +1,23 @@ +/* + * Copyright 2023-2025 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.cohere.autoconfigure; import org.junit.jupiter.api.Test; + import org.springframework.ai.cohere.chat.CohereChatModel; import org.springframework.ai.cohere.embedding.CohereEmbeddingModel; import org.springframework.ai.utils.SpringAiTestAutoConfigurations; diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/test/java/org/springframework/ai/cohere/autoconfigure/CoherePropertiesTests.java b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/test/java/org/springframework/ai/cohere/autoconfigure/CoherePropertiesTests.java index 89e5a84eb45..ff7f6fd88b4 100644 --- a/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/test/java/org/springframework/ai/cohere/autoconfigure/CoherePropertiesTests.java +++ b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/test/java/org/springframework/ai/cohere/autoconfigure/CoherePropertiesTests.java @@ -1,11 +1,28 @@ +/* + * Copyright 2023-2025 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.cohere.autoconfigure; import org.junit.jupiter.api.Test; + import org.springframework.ai.cohere.api.CohereApi; import org.springframework.ai.retry.autoconfigure.SpringAiRetryAutoConfiguration; import org.springframework.ai.utils.SpringAiTestAutoConfigurations; import org.springframework.boot.autoconfigure.AutoConfigurations; -import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration; +import org.springframework.boot.restclient.autoconfigure.RestClientAutoConfiguration; import org.springframework.boot.test.context.runner.ApplicationContextRunner; import static org.assertj.core.api.Assertions.assertThat; diff --git a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/aot/CohereRuntimeHints.java b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/aot/CohereRuntimeHints.java index cf159ca27bc..b25b5a4843d 100644 --- a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/aot/CohereRuntimeHints.java +++ b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/aot/CohereRuntimeHints.java @@ -19,8 +19,6 @@ import org.springframework.aot.hint.MemberCategory; import org.springframework.aot.hint.RuntimeHints; import org.springframework.aot.hint.RuntimeHintsRegistrar; -import org.springframework.lang.NonNull; -import org.springframework.lang.Nullable; import static org.springframework.ai.aot.AiRuntimeHints.findJsonAnnotatedClassesInPackage; @@ -33,7 +31,7 @@ public class CohereRuntimeHints implements RuntimeHintsRegistrar { @Override - public void registerHints(@NonNull RuntimeHints hints, @Nullable ClassLoader classLoader) { + public void registerHints(final RuntimeHints hints, final ClassLoader classLoader) { var mcs = MemberCategory.values(); for (var tr : findJsonAnnotatedClassesInPackage("org.springframework.ai.cohere")) { diff --git a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/api/CohereApi.java b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/api/CohereApi.java index e9064267cd2..74251828d8e 100644 --- a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/api/CohereApi.java +++ b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/api/CohereApi.java @@ -16,7 +16,20 @@ package org.springframework.ai.cohere.api; -import com.fasterxml.jackson.annotation.*; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Consumer; +import java.util.function.Predicate; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + import org.springframework.ai.model.ChatModelDescription; import org.springframework.ai.model.ModelOptionsUtils; import org.springframework.ai.observation.conventions.AiProvider; @@ -30,14 +43,6 @@ import org.springframework.web.client.ResponseErrorHandler; import org.springframework.web.client.RestClient; import org.springframework.web.reactive.function.client.WebClient; -import reactor.core.publisher.Flux; -import reactor.core.publisher.Mono; - -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.function.Consumer; -import java.util.function.Predicate; /** * Java Client library for Cohere Platform. Provides implementation for the @@ -106,6 +111,132 @@ public CohereApi(String baseUrl, String cohereApiKey, RestClient.Builder restCli this.webClient = webClientBuilder.clone().baseUrl(baseUrl).defaultHeaders(jsonContentHeaders).build(); } + /** + * 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 stream property to false."); + + return this.restClient.post().uri("/v2/chat/").body(chatRequest).retrieve().toEntity(ChatCompletion.class); + } + + /** + * Creates an embedding vector representing the input text or token array. + * @param embeddingRequest The embedding request. + * @return Returns {@link EmbeddingResponse} with embeddings data. + * @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")} 
+ */ + public ResponseEntity embeddings(EmbeddingRequest embeddingRequest) { + + Assert.notNull(embeddingRequest, "The request body can not be null."); + + Assert.isTrue(!CollectionUtils.isEmpty(embeddingRequest.texts), "The texts list can not be empty."); + Assert.isTrue(embeddingRequest.texts.size() <= 96, "The list must be 96 items or less"); + + return this.restClient.post() + .uri("/v2/embed") + .body(embeddingRequest) + .retrieve() + .toEntity(new ParameterizedTypeReference<>() { + + }); + } + + /** + * 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 stream property to true."); + + return this.webClient.post() + .uri("v2/chat") + .body(Mono.just(chatRequest), ChatCompletionRequest.class) + .retrieve() + .bodyToFlux(String.class) + .takeUntil(SSE_DONE_PREDICATE) + .filter(SSE_DONE_PREDICATE.negate()) + .map(content -> ModelOptionsUtils.jsonToObject(content, ChatCompletionChunk.class)) + .groupBy(chunk -> chunk.id() != null ? chunk.id() : "no-id") + .flatMap(group -> group.reduce(new ChatCompletionChunk(null, null, null, null), this.chunkMerger::merge) + .filter(chunk -> EventType.MESSAGE_END.value.equals(chunk.type()) + || (chunk.delta() != null && chunk.delta().finishReason() != null))) + .map(this.chunkMerger::sanitizeToolCalls) + .filter(this.chunkMerger::hasValidToolCallsOnly) + .filter(Objects::nonNull); + } + + public static Builder builder() { + return new Builder(); + } + + /** + * Builder for creating CohereApi instances. + */ + public static class Builder { + + private String baseUrl = DEFAULT_BASE_URL; + + private String apiKey; + + private RestClient.Builder restClientBuilder = RestClient.builder(); + + private WebClient.Builder webClientBuilder = WebClient.builder(); + + private ResponseErrorHandler responseErrorHandler = RetryUtils.DEFAULT_RESPONSE_ERROR_HANDLER; + + public Builder baseUrl(String baseUrl) { + this.baseUrl = baseUrl; + return this; + } + + public Builder apiKey(String apiKey) { + this.apiKey = apiKey; + return this; + } + + public Builder restClientBuilder(RestClient.Builder restClientBuilder) { + this.restClientBuilder = restClientBuilder; + return this; + } + + public Builder webClientBuilder(WebClient.Builder webClientBuilder) { + this.webClientBuilder = webClientBuilder; + return this; + } + + public Builder responseErrorHandler(ResponseErrorHandler responseErrorHandler) { + this.responseErrorHandler = responseErrorHandler; + return this; + } + + public CohereApi build() { + Assert.hasText(this.apiKey, "Cohere API key must be set"); + Assert.hasText(this.baseUrl, "Cohere base URL must be set"); + Assert.notNull(this.restClientBuilder, "RestClient.Builder must not be null"); + Assert.notNull(this.webClientBuilder, "WebClient.Builder must not be null"); + Assert.notNull(this.responseErrorHandler, "ResponseErrorHandler must not be null"); + + return new CohereApi(this.baseUrl, this.apiKey, this.restClientBuilder, this.webClientBuilder, + this.responseErrorHandler); + } + + } + /** * List of well-known Cohere chat models. * @@ -824,20 +955,6 @@ public static Object FUNCTION(String functionName) { } - /** - * 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 stream property to false."); - - return this.restClient.post().uri("/v2/chat/").body(chatRequest).retrieve().toEntity(ChatCompletion.class); - } - @JsonInclude(JsonInclude.Include.NON_NULL) @JsonIgnoreProperties(ignoreUnknown = true) public record ChatCompletionChunk( @@ -1036,7 +1153,8 @@ public Builder truncate(Truncate truncate) { } public EmbeddingRequest build() { - return new EmbeddingRequest<>(texts, model, inputType, embeddingTypes, truncate); + return new EmbeddingRequest<>(this.texts, this.model, this.inputType, this.embeddingTypes, + this.truncate); } } @@ -1151,61 +1269,6 @@ public List getFloatEmbeddings() { } - /** - * Creates an embedding vector representing the input text or token array. - * @param embeddingRequest The embedding request. - * @return Returns {@link EmbeddingResponse} with embeddings data. - * @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")} 
- */ - public ResponseEntity embeddings(EmbeddingRequest embeddingRequest) { - - Assert.notNull(embeddingRequest, "The request body can not be null."); - - Assert.isTrue(!CollectionUtils.isEmpty(embeddingRequest.texts), "The texts list can not be empty."); - Assert.isTrue(embeddingRequest.texts.size() <= 96, "The list must be 96 items or less"); - - return this.restClient.post() - .uri("/v2/embed") - .body(embeddingRequest) - .retrieve() - .toEntity(new ParameterizedTypeReference<>() { - - }); - } - - /** - * 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 stream property to true."); - - return this.webClient.post() - .uri("v2/chat") - .body(Mono.just(chatRequest), ChatCompletionRequest.class) - .retrieve() - .bodyToFlux(String.class) - .takeUntil(SSE_DONE_PREDICATE) - .filter(SSE_DONE_PREDICATE.negate()) - .map(content -> ModelOptionsUtils.jsonToObject(content, ChatCompletionChunk.class)) - .groupBy(chunk -> chunk.id() != null ? chunk.id() : "no-id") - .flatMap(group -> group.reduce(new ChatCompletionChunk(null, null, null, null), this.chunkMerger::merge) - .filter(chunk -> EventType.MESSAGE_END.value.equals(chunk.type()) - || (chunk.delta() != null && chunk.delta().finishReason() != null))) - .map(chunkMerger::sanitizeToolCalls) - .filter(chunkMerger::hasValidToolCallsOnly) - .filter(Objects::nonNull); - } - public enum EventType { MESSAGE_END("message-end"), CONTENT_START("content-start"), CONTENT_DELTA("content-delta"), @@ -1224,61 +1287,4 @@ public String getValue() { } - public static Builder builder() { - return new Builder(); - } - - /** - * Builder for creating CohereApi instances. - */ - public static class Builder { - - private String baseUrl = DEFAULT_BASE_URL; - - private String apiKey; - - private RestClient.Builder restClientBuilder = RestClient.builder(); - - private WebClient.Builder webClientBuilder = WebClient.builder(); - - private ResponseErrorHandler responseErrorHandler = RetryUtils.DEFAULT_RESPONSE_ERROR_HANDLER; - - public Builder baseUrl(String baseUrl) { - this.baseUrl = baseUrl; - return this; - } - - public Builder apiKey(String apiKey) { - this.apiKey = apiKey; - return this; - } - - public Builder restClientBuilder(RestClient.Builder restClientBuilder) { - this.restClientBuilder = restClientBuilder; - return this; - } - - public Builder webClientBuilder(WebClient.Builder webClientBuilder) { - this.webClientBuilder = webClientBuilder; - return this; - } - - public Builder responseErrorHandler(ResponseErrorHandler responseErrorHandler) { - this.responseErrorHandler = responseErrorHandler; - return this; - } - - public CohereApi build() { - Assert.hasText(this.apiKey, "Cohere API key must be set"); - Assert.hasText(this.baseUrl, "Cohere base URL must be set"); - Assert.notNull(this.restClientBuilder, "RestClient.Builder must not be null"); - Assert.notNull(this.webClientBuilder, "WebClient.Builder must not be null"); - Assert.notNull(this.responseErrorHandler, "ResponseErrorHandler must not be null"); - - return new CohereApi(this.baseUrl, this.apiKey, this.restClientBuilder, this.webClientBuilder, - this.responseErrorHandler); - } - - } - } diff --git a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/api/CohereStreamFunctionCallingHelper.java b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/api/CohereStreamFunctionCallingHelper.java index 00a25203ee5..63a961ac972 100644 --- a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/api/CohereStreamFunctionCallingHelper.java +++ b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/api/CohereStreamFunctionCallingHelper.java @@ -16,20 +16,21 @@ package org.springframework.ai.cohere.api; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + import org.springframework.ai.cohere.api.CohereApi.ChatCompletionChunk; import org.springframework.ai.cohere.api.CohereApi.ChatCompletionMessage; import org.springframework.ai.cohere.api.CohereApi.ChatCompletionMessage.ChatCompletionFunction; import org.springframework.ai.cohere.api.CohereApi.ChatCompletionMessage.Role; import org.springframework.ai.cohere.api.CohereApi.ChatCompletionMessage.ToolCall; +import org.springframework.ai.cohere.api.CohereApi.EventType; import org.springframework.util.ObjectUtils; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; - -import static org.springframework.ai.cohere.api.CohereApi.EventType.*; - /** + * Helper class for handling streaming function calling in Cohere API. + * * @author Ricken Bazolo */ public class CohereStreamFunctionCallingHelper { @@ -65,10 +66,10 @@ public ChatCompletionChunk merge(ChatCompletionChunk previous, ChatCompletionChu String currentType = current.type(); String mergedText; - if (CONTENT_START.getValue().equals(currentType)) { + if (EventType.CONTENT_START.getValue().equals(currentType)) { mergedText = currentText; } - else if (CONTENT_END.getValue().equals(currentType)) { + else if (EventType.CONTENT_END.getValue().equals(currentType)) { mergedText = previousText; } else { @@ -80,7 +81,7 @@ else if (CONTENT_END.getValue().equals(currentType)) { String mergedToolPlan = previousPlan; - if (TOOL_PLAN_DELTA.getValue().equals(current.type())) { + if (EventType.TOOL_PLAN_DELTA.getValue().equals(current.type())) { mergedToolPlan = mergeToolPlan(previousPlan, currentPlan); } @@ -107,14 +108,16 @@ else if (CONTENT_END.getValue().equals(currentType)) { } public ChatCompletionChunk sanitizeToolCalls(ChatCompletionChunk chunk) { - if (chunk == null || chunk.delta() == null || chunk.delta().message() == null) + if (chunk == null || chunk.delta() == null || chunk.delta().message() == null) { return chunk; + } ChatCompletionMessage msg = chunk.delta().message(); List toolCalls = msg.toolCalls(); - if (toolCalls == null || toolCalls.isEmpty()) + if (toolCalls == null || toolCalls.isEmpty()) { return chunk; + } List cleaned = toolCalls.stream().filter(this::isValidToolCall).toList(); @@ -130,8 +133,9 @@ public ChatCompletionChunk sanitizeToolCalls(ChatCompletionChunk chunk) { } public boolean hasValidToolCallsOnly(ChatCompletionChunk c) { - if (c == null || c.delta() == null || c.delta().message() == null) + if (c == null || c.delta() == null || c.delta().message() == null) { return false; + } ChatCompletionMessage message = c.delta().message(); List calls = message.toolCalls(); @@ -162,16 +166,18 @@ private String extractTextFromRawContent(Object rawContent) { } if (rawContent instanceof Map map) { Object text = map.get("text"); - if (text != null) + if (text != null) { return text.toString(); + } } if (rawContent instanceof List list) { StringBuilder sb = new StringBuilder(); for (Object item : list) { if (item instanceof Map m) { Object text = m.get("text"); - if (text != null) + if (text != null) { sb.append(text); + } } else if (item instanceof String s) { sb.append(s); @@ -179,8 +185,9 @@ else if (item instanceof String s) { } return sb.toString(); } - if (rawContent instanceof String s) + if (rawContent instanceof String s) { return s; + } return rawContent.toString(); } @@ -207,8 +214,8 @@ private List mergeToolCalls(ChatCompletionChunk previous, ChatCompleti String functionName = existingFunction.name(); String args = existingFunction.arguments() != null ? existingFunction.arguments() : ""; - if (TOOL_CALL_START.getValue().equals(type) && currentMessage != null && currentMessage.toolCalls() != null - && !currentMessage.toolCalls().isEmpty()) { + if (EventType.TOOL_CALL_START.getValue().equals(type) && currentMessage != null + && currentMessage.toolCalls() != null && !currentMessage.toolCalls().isEmpty()) { ToolCall start = currentMessage.toolCalls().get(0); ChatCompletionFunction startFunction = start.function() != null ? start.function() @@ -220,8 +227,8 @@ private List mergeToolCalls(ChatCompletionChunk previous, ChatCompleti } - if (TOOL_CALL_DELTA.getValue().equals(type) && currentMessage != null && currentMessage.toolCalls() != null - && !currentMessage.toolCalls().isEmpty()) { + if (EventType.TOOL_CALL_DELTA.getValue().equals(type) && currentMessage != null + && currentMessage.toolCalls() != null && !currentMessage.toolCalls().isEmpty()) { ToolCall deltaCall = currentMessage.toolCalls().get(0); ChatCompletionFunction deltaFunction = deltaCall.function(); @@ -238,7 +245,7 @@ private List mergeToolCalls(ChatCompletionChunk previous, ChatCompleti return merged; } - private String mergeToolPlan(String previous, String currentFragment) { + private String mergeToolPlan(final String previous, final String currentFragment) { if (currentFragment == null || currentFragment.isEmpty()) { return previous; } @@ -248,8 +255,8 @@ private String mergeToolPlan(String previous, String currentFragment) { return previous + currentFragment; } - private List mergeCitations(ChatCompletionChunk previous, - ChatCompletionChunk current) { + private List mergeCitations(final ChatCompletionChunk previous, + final ChatCompletionChunk current) { ChatCompletionMessage previousMessage = previous != null && previous.delta() != null ? previous.delta().message() : null; @@ -262,7 +269,7 @@ private List mergeCitations(ChatCo merged.addAll(previousMessage.citations()); } - if (current != null && CITATION_START.getValue().equals(current.type()) && currentMessage != null + if (current != null && EventType.CITATION_START.getValue().equals(current.type()) && currentMessage != null && currentMessage.citations() != null) { merged.addAll(currentMessage.citations()); } @@ -270,11 +277,11 @@ private List mergeCitations(ChatCo return merged.isEmpty() ? null : merged; } - private List ensureToolCallList(List toolCalls) { + private List ensureToolCallList(final List toolCalls) { return (toolCalls != null) ? new ArrayList<>(toolCalls) : new ArrayList<>(); } - private ToolCall ensureToolCallAtIndex(List toolCalls, int index) { + private ToolCall ensureToolCallAtIndex(final List toolCalls, final int index) { while (toolCalls.size() <= index) { toolCalls.add(new ToolCall(null, null, new ChatCompletionFunction("", ""), index)); } diff --git a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/chat/CohereChatModel.java b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/chat/CohereChatModel.java index e09a56a872c..ef89236eda3 100644 --- a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/chat/CohereChatModel.java +++ b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/chat/CohereChatModel.java @@ -16,57 +16,62 @@ package org.springframework.ai.cohere.chat; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + import io.micrometer.observation.Observation; import io.micrometer.observation.ObservationRegistry; import io.micrometer.observation.contextpropagation.ObservationThreadLocalAccessor; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; -import org.springframework.ai.chat.messages.*; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.ToolResponseMessage; +import org.springframework.ai.chat.messages.UserMessage; import org.springframework.ai.chat.metadata.ChatGenerationMetadata; -import org.springframework.ai.chat.model.MessageAggregator; -import org.springframework.ai.cohere.api.CohereApi; -import org.springframework.ai.cohere.api.CohereApi.FunctionTool; -import org.springframework.ai.cohere.api.CohereApi.ChatCompletion; -import org.springframework.ai.cohere.api.CohereApi.ChatCompletionMessage; -import org.springframework.ai.cohere.api.CohereApi.ChatCompletionMessage.ChatCompletionFunction; -import org.springframework.ai.cohere.api.CohereApi.ChatCompletionMessage.ToolCall; -import org.springframework.ai.cohere.api.CohereApi.ChatCompletionMessage.Role; -import org.springframework.ai.cohere.api.CohereApi.ChatCompletionRequest; import org.springframework.ai.chat.metadata.ChatResponseMetadata; import org.springframework.ai.chat.metadata.DefaultUsage; import org.springframework.ai.chat.metadata.Usage; import org.springframework.ai.chat.model.ChatModel; import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.chat.model.Generation; +import org.springframework.ai.chat.model.MessageAggregator; import org.springframework.ai.chat.observation.ChatModelObservationContext; import org.springframework.ai.chat.observation.ChatModelObservationConvention; import org.springframework.ai.chat.observation.ChatModelObservationDocumentation; import org.springframework.ai.chat.observation.DefaultChatModelObservationConvention; import org.springframework.ai.chat.prompt.ChatOptions; import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.cohere.api.CohereApi; +import org.springframework.ai.cohere.api.CohereApi.ChatCompletion; +import org.springframework.ai.cohere.api.CohereApi.ChatCompletionMessage; +import org.springframework.ai.cohere.api.CohereApi.ChatCompletionMessage.Role; +import org.springframework.ai.cohere.api.CohereApi.ChatCompletionMessage.ToolCall; +import org.springframework.ai.cohere.api.CohereApi.ChatCompletionRequest; +import org.springframework.ai.cohere.api.CohereApi.FunctionTool; import org.springframework.ai.content.Media; import org.springframework.ai.model.ModelOptionsUtils; import org.springframework.ai.model.tool.DefaultToolExecutionEligibilityPredicate; +import org.springframework.ai.model.tool.ToolCallingChatOptions; import org.springframework.ai.model.tool.ToolCallingManager; import org.springframework.ai.model.tool.ToolExecutionEligibilityPredicate; import org.springframework.ai.model.tool.ToolExecutionResult; -import org.springframework.ai.model.tool.ToolCallingChatOptions; import org.springframework.ai.model.tool.internal.ToolCallReactiveContextHolder; import org.springframework.ai.retry.RetryUtils; import org.springframework.ai.support.UsageCalculator; import org.springframework.ai.tool.definition.ToolDefinition; +import org.springframework.core.retry.RetryTemplate; import org.springframework.http.ResponseEntity; -import org.springframework.retry.support.RetryTemplate; import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; import org.springframework.util.MimeType; -import reactor.core.publisher.Flux; -import reactor.core.publisher.Mono; -import reactor.core.scheduler.Schedulers; - -import java.util.*; -import java.util.concurrent.ConcurrentHashMap; /** * Represents a Cohere Chat Model. @@ -176,8 +181,8 @@ public Flux internalStream(Prompt prompt, ChatResponse previousCha observation.parentObservation(contextView.getOrDefault(ObservationThreadLocalAccessor.KEY, null)).start(); - Flux completionChunks = this.retryTemplate - .execute(ctx -> this.cohereApi.chatCompletionStream(request)); + Flux completionChunks = RetryUtils.execute(this.retryTemplate, + () -> this.cohereApi.chatCompletionStream(request)); // For chunked responses, only the first chunk contains the role. // The rest of the chunks with same ID share the same role. @@ -371,8 +376,8 @@ private ChatResponse internalCall(Prompt prompt, ChatResponse previousChatRespon this.observationRegistry) .observe(() -> { - ResponseEntity completionEntity = this.retryTemplate - .execute(ctx -> this.cohereApi.chatCompletionEntity(request)); + ResponseEntity completionEntity = RetryUtils.execute(this.retryTemplate, + () -> this.cohereApi.chatCompletionEntity(request)); ChatCompletion chatCompletion = completionEntity.getBody(); @@ -537,7 +542,7 @@ private List convertAssistantMessage(org.springframework. */ private List convertToolCalls(List springToolCalls) { return springToolCalls.stream().map(toolCall -> { - var function = new ChatCompletionFunction(toolCall.name(), toolCall.arguments()); + var function = new ChatCompletionMessage.ChatCompletionFunction(toolCall.name(), toolCall.arguments()); return new ToolCall(toolCall.id(), toolCall.type(), function, null); }).toList(); } diff --git a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/chat/CohereChatOptions.java b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/chat/CohereChatOptions.java index 0bdab0fe169..5c5df1a5a37 100644 --- a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/chat/CohereChatOptions.java +++ b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/chat/CohereChatOptions.java @@ -16,6 +16,15 @@ package org.springframework.ai.cohere.chat; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; @@ -29,15 +38,6 @@ import org.springframework.lang.Nullable; import org.springframework.util.Assert; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Set; - /** * Options for the Cohere API. * @@ -79,7 +79,7 @@ public class CohereChatOptions implements ToolCallingChatOptions { private @JsonProperty("presence_penalty") Double presencePenalty; /** - * Nin value of 0.0, max value of 1.0. Used to reduce repetitiveness of generated + * Min value of 0.0, max value of 1.0. Used to reduce repetitiveness of generated * tokens. Similar to frequency_penalty, except that this penalty is applied equally * to all tokens that have already appeared, regardless of their exact frequencies. */ @@ -384,24 +384,27 @@ public boolean equals(Object o) { return false; } CohereChatOptions that = (CohereChatOptions) o; - return Objects.equals(model, that.model) && Objects.equals(temperature, that.temperature) - && Objects.equals(p, that.p) && Objects.equals(maxTokens, that.maxTokens) - && Objects.equals(presencePenalty, that.presencePenalty) - && Objects.equals(frequencyPenalty, that.frequencyPenalty) && Objects.equals(k, that.k) - && Objects.equals(tools, that.tools) && Objects.equals(responseFormat, that.responseFormat) - && Objects.equals(safetyMode, that.safetyMode) && Objects.equals(stopSequences, that.stopSequences) - && Objects.equals(seed, that.seed) && Objects.equals(logprobs, that.logprobs) - && Objects.equals(toolChoice, that.toolChoice) && Objects.equals(strictTools, that.strictTools) - && Objects.equals(toolCallbacks, that.toolCallbacks) && Objects.equals(toolNames, that.toolNames) - && Objects.equals(internalToolExecutionEnabled, that.internalToolExecutionEnabled) - && Objects.equals(toolContext, that.toolContext); + return Objects.equals(this.model, that.model) && Objects.equals(this.temperature, that.temperature) + && Objects.equals(this.p, that.p) && Objects.equals(this.maxTokens, that.maxTokens) + && Objects.equals(this.presencePenalty, that.presencePenalty) + && Objects.equals(this.frequencyPenalty, that.frequencyPenalty) && Objects.equals(this.k, that.k) + && Objects.equals(this.tools, that.tools) && Objects.equals(this.responseFormat, that.responseFormat) + && Objects.equals(this.safetyMode, that.safetyMode) + && Objects.equals(this.stopSequences, that.stopSequences) && Objects.equals(this.seed, that.seed) + && Objects.equals(this.logprobs, that.logprobs) && Objects.equals(this.toolChoice, that.toolChoice) + && Objects.equals(this.strictTools, that.strictTools) + && Objects.equals(this.toolCallbacks, that.toolCallbacks) + && Objects.equals(this.toolNames, that.toolNames) + && Objects.equals(this.internalToolExecutionEnabled, that.internalToolExecutionEnabled) + && Objects.equals(this.toolContext, that.toolContext); } @Override public int hashCode() { - return Objects.hash(model, temperature, p, maxTokens, presencePenalty, frequencyPenalty, k, tools, - responseFormat, safetyMode, stopSequences, seed, logprobs, toolChoice, strictTools, toolCallbacks, - toolNames, internalToolExecutionEnabled, toolContext); + return Objects.hash(this.model, this.temperature, this.p, this.maxTokens, this.presencePenalty, + this.frequencyPenalty, this.k, this.tools, this.responseFormat, this.safetyMode, this.stopSequences, + this.seed, this.logprobs, this.toolChoice, this.strictTools, this.toolCallbacks, this.toolNames, + this.internalToolExecutionEnabled, this.toolContext); } public static Builder builder() { @@ -409,8 +412,7 @@ public static Builder builder() { } public static CohereChatOptions fromOptions(CohereChatOptions fromOptions) { - Builder builder = CohereChatOptions.builder() - .model(fromOptions.getModel()) + Builder builder = builder().model(fromOptions.getModel()) .temperature(fromOptions.getTemperature()) .maxTokens(fromOptions.getMaxTokens()) .topP(fromOptions.getTopP()) @@ -446,8 +448,7 @@ public static CohereChatOptions fromOptions(CohereChatOptions fromOptions) { } public static CohereChatOptions fromOptions2(CohereChatOptions fromOptions) { - return CohereChatOptions.builder() - .model(fromOptions.getModel()) + return builder().model(fromOptions.getModel()) .temperature(fromOptions.getTemperature()) .maxTokens(fromOptions.getMaxTokens()) .topP(fromOptions.getTopP()) diff --git a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/embedding/CohereEmbeddingModel.java b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/embedding/CohereEmbeddingModel.java index 86fd0e09402..d0d526a0a0a 100644 --- a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/embedding/CohereEmbeddingModel.java +++ b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/embedding/CohereEmbeddingModel.java @@ -16,30 +16,36 @@ package org.springframework.ai.cohere.embedding; +import java.util.List; +import java.util.Map; + import io.micrometer.observation.ObservationRegistry; import org.slf4j.Logger; import org.slf4j.LoggerFactory; + import org.springframework.ai.cohere.api.CohereApi; import org.springframework.ai.document.Document; import org.springframework.ai.document.MetadataMode; -import org.springframework.ai.embedding.*; +import org.springframework.ai.embedding.AbstractEmbeddingModel; +import org.springframework.ai.embedding.Embedding; +import org.springframework.ai.embedding.EmbeddingOptions; +import org.springframework.ai.embedding.EmbeddingRequest; +import org.springframework.ai.embedding.EmbeddingResponse; +import org.springframework.ai.embedding.EmbeddingResponseMetadata; import org.springframework.ai.embedding.observation.DefaultEmbeddingModelObservationConvention; import org.springframework.ai.embedding.observation.EmbeddingModelObservationContext; import org.springframework.ai.embedding.observation.EmbeddingModelObservationConvention; import org.springframework.ai.embedding.observation.EmbeddingModelObservationDocumentation; import org.springframework.ai.model.ModelOptionsUtils; import org.springframework.ai.retry.RetryUtils; -import org.springframework.retry.support.RetryTemplate; +import org.springframework.core.retry.RetryTemplate; import org.springframework.util.Assert; -import java.util.List; -import java.util.Map; - /** * Provides the Cohere Embedding Model. * - * @see AbstractEmbeddingModel * @author Ricken Bazolo + * @see AbstractEmbeddingModel */ public class CohereEmbeddingModel extends AbstractEmbeddingModel { @@ -97,7 +103,7 @@ public EmbeddingResponse call(EmbeddingRequest request) { var apiRequest = createRequest(request); - EmbeddingModelObservationContext observationContext = EmbeddingModelObservationContext.builder() + var observationContext = EmbeddingModelObservationContext.builder() .embeddingRequest(request) .provider(CohereApi.PROVIDER_NAME) .build(); @@ -106,9 +112,8 @@ public EmbeddingResponse call(EmbeddingRequest request) { .observation(this.observationConvention, DEFAULT_OBSERVATION_CONVENTION, () -> observationContext, this.observationRegistry) .observe(() -> { - - var apiEmbeddingResponse = this.retryTemplate - .execute(ctx -> this.cohereApi.embeddings(apiRequest).getBody()); + var apiEmbeddingResponse = RetryUtils.execute(this.retryTemplate, + () -> this.cohereApi.embeddings(apiRequest).getBody()); if (apiEmbeddingResponse == null) { logger.warn("No embeddings returned for request: {}", request); @@ -117,7 +122,7 @@ public EmbeddingResponse call(EmbeddingRequest request) { var metadata = generateResponseMetadata(apiEmbeddingResponse.responseType()); - // Extract float embeddings from the response using the helper method + // Extract float embeddings from response List floatEmbeddings = apiEmbeddingResponse.getFloatEmbeddings(); // Map to Spring AI Embedding objects with proper indexing @@ -131,7 +136,6 @@ public EmbeddingResponse call(EmbeddingRequest request) { observationContext.setResponse(embeddingResponse); return embeddingResponse; - }); } @@ -146,7 +150,7 @@ private EmbeddingResponseMetadata generateResponseMetadata(String embeddingType) } /** - * Use the provided convention for reporting observation data + * Use the provided convention for reporting observation data. * @param observationConvention The provided convention */ public void setObservationConvention(EmbeddingModelObservationConvention observationConvention) { diff --git a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/embedding/CohereEmbeddingOptions.java b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/embedding/CohereEmbeddingOptions.java index e717d2bfe71..6b801ea26bb 100644 --- a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/embedding/CohereEmbeddingOptions.java +++ b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/embedding/CohereEmbeddingOptions.java @@ -16,28 +16,29 @@ package org.springframework.ai.cohere.embedding; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; + import org.springframework.ai.cohere.api.CohereApi; import org.springframework.ai.cohere.api.CohereApi.EmbeddingRequest.InputType; import org.springframework.ai.cohere.api.CohereApi.EmbeddingRequest.Truncate; import org.springframework.ai.cohere.api.CohereApi.EmbeddingType; import org.springframework.ai.embedding.EmbeddingOptions; -import java.util.ArrayList; -import java.util.List; -import java.util.Objects; - /** * Options for the Cohere Embedding API. * * @author Ricken Bazolo */ @JsonInclude(JsonInclude.Include.NON_NULL) -public class CohereEmbeddingOptions implements EmbeddingOptions { +public final class CohereEmbeddingOptions implements EmbeddingOptions { /** - * ID of the model to use + * ID of the model to use. */ @JsonProperty("model") private String model; diff --git a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/schema/CohereToolCallingManager.java b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/schema/CohereToolCallingManager.java index c8d9b98d0fd..879ab21b86b 100644 --- a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/schema/CohereToolCallingManager.java +++ b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/schema/CohereToolCallingManager.java @@ -16,19 +16,16 @@ package org.springframework.ai.cohere.schema; -import com.fasterxml.jackson.databind.node.ObjectNode; +import java.util.List; + import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.chat.prompt.Prompt; import org.springframework.ai.model.tool.ToolCallingChatOptions; import org.springframework.ai.model.tool.ToolCallingManager; import org.springframework.ai.model.tool.ToolExecutionResult; -import org.springframework.ai.tool.definition.DefaultToolDefinition; import org.springframework.ai.tool.definition.ToolDefinition; -import org.springframework.ai.util.json.schema.JsonSchemaGenerator; import org.springframework.util.Assert; -import java.util.List; - /** * Implementation of {@link ToolCallingManager} specifically designed for Vertex AI * Gemini. This manager adapts tool definitions to be compatible with Vertex AI's OpenAPI diff --git a/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/CohereRetryTests.java b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/CohereRetryTests.java index fbf2b415613..89d4cdb2b84 100644 --- a/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/CohereRetryTests.java +++ b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/CohereRetryTests.java @@ -16,16 +16,18 @@ package org.springframework.ai.cohere; +import java.util.List; +import java.util.Optional; + import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; + import org.springframework.ai.chat.prompt.Prompt; import org.springframework.ai.cohere.api.CohereApi; import org.springframework.ai.cohere.api.CohereApi.ChatCompletion; -import org.springframework.ai.cohere.api.CohereApi.ChatCompletionChunk; import org.springframework.ai.cohere.api.CohereApi.ChatCompletionFinishReason; import org.springframework.ai.cohere.api.CohereApi.ChatCompletionMessage; import org.springframework.ai.cohere.api.CohereApi.ChatCompletionMessage.Role; @@ -38,15 +40,11 @@ import org.springframework.ai.cohere.embedding.CohereEmbeddingModel; import org.springframework.ai.retry.RetryUtils; import org.springframework.ai.retry.TransientAiException; +import org.springframework.core.retry.RetryListener; +import org.springframework.core.retry.RetryPolicy; +import org.springframework.core.retry.RetryTemplate; +import org.springframework.core.retry.Retryable; import org.springframework.http.ResponseEntity; -import org.springframework.retry.RetryCallback; -import org.springframework.retry.RetryContext; -import org.springframework.retry.RetryListener; -import org.springframework.retry.support.RetryTemplate; -import reactor.core.publisher.Flux; - -import java.util.List; -import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -74,7 +72,7 @@ public class CohereRetryTests { public void beforeEach() { this.retryTemplate = RetryUtils.SHORT_RETRY_TEMPLATE; this.retryListener = new TestRetryListener(); - this.retryTemplate.registerListener(this.retryListener); + this.retryTemplate.setRetryListener(this.retryListener); this.chatModel = CohereChatModel.builder() .cohereApi(this.cohereApi) @@ -109,8 +107,8 @@ public void cohereChatTransientError() { assertThat(result).isNotNull(); assertThat(result.getResult().getOutput().getText()).isEqualTo("Response"); - assertThat(this.retryListener.onSuccessRetryCount).isEqualTo(2); - assertThat(this.retryListener.onErrorRetryCount).isEqualTo(2); + assertThat(this.retryListener.onSuccessRetryCount).isEqualTo(1); + assertThat(this.retryListener.retryCount).isEqualTo(2); } @Test @@ -121,37 +119,6 @@ public void cohereChatNonTransientError() { } @Test - @Disabled("Currently stream() does not implement retry") - public void cohereChatStreamTransientError() { - var message = new ChatCompletionMessage("Response", Role.ASSISTANT); - - var delta = new ChatCompletionChunk.ChunkDelta(message, ChatCompletionFinishReason.COMPLETE, null); - - ChatCompletionChunk expectedChunk = new ChatCompletionChunk("id", "content-delta", 0, delta); - - given(this.cohereApi.chatCompletionStream(isA(ChatCompletionRequest.class))) - .willThrow(new TransientAiException("Transient Error 1")) - .willThrow(new TransientAiException("Transient Error 2")) - .willReturn(Flux.just(expectedChunk)); - - var result = this.chatModel.stream(new Prompt("text")); - - assertThat(result).isNotNull(); - assertThat(result.collectList().block().get(0).getResult().getOutput().getText()).isEqualTo("Response"); - assertThat(this.retryListener.onSuccessRetryCount).isEqualTo(2); - assertThat(this.retryListener.onErrorRetryCount).isEqualTo(2); - } - - @Test - @Disabled("Currently stream() does not implement retry") - public void cohereChatStreamNonTransientError() { - given(this.cohereApi.chatCompletionStream(isA(ChatCompletionRequest.class))) - .willThrow(new RuntimeException("Non Transient Error")); - assertThrows(RuntimeException.class, () -> this.chatModel.stream(new Prompt("text"))); - } - - @Test - @Disabled("Embedding tests need to be adapted for Cohere API structure") public void cohereEmbeddingTransientError() { List> embeddingsList = List.of(List.of(9.9, 8.8), List.of(7.7, 6.6)); @@ -167,8 +134,8 @@ public void cohereEmbeddingTransientError() { .call(new org.springframework.ai.embedding.EmbeddingRequest(List.of("text1", "text2"), null)); assertThat(result).isNotNull(); - assertThat(this.retryListener.onSuccessRetryCount).isEqualTo(2); - assertThat(this.retryListener.onErrorRetryCount).isEqualTo(2); + assertThat(this.retryListener.onSuccessRetryCount).isEqualTo(1); + assertThat(this.retryListener.retryCount).isEqualTo(2); } @Test @@ -189,24 +156,24 @@ public void cohereChatMixedTransientAndNonTransientErrors() { assertThrows(RuntimeException.class, () -> this.chatModel.call(new Prompt("text"))); // Should have 1 retry attempt before hitting non-transient error - assertThat(this.retryListener.onErrorRetryCount).isEqualTo(2); + assertThat(this.retryListener.retryCount).isEqualTo(1); } private static class TestRetryListener implements RetryListener { - int onErrorRetryCount = 0; + int retryCount = 0; int onSuccessRetryCount = 0; @Override - public void onSuccess(RetryContext context, RetryCallback callback, T result) { - this.onSuccessRetryCount = context.getRetryCount(); + public void onRetrySuccess(final RetryPolicy retryPolicy, final Retryable retryable, final Object result) { + // Count successful retries - we increment when we succeed after a failure + this.onSuccessRetryCount++; } @Override - public void onError(RetryContext context, RetryCallback callback, - Throwable throwable) { - this.onErrorRetryCount = context.getRetryCount(); + public void beforeRetry(RetryPolicy retryPolicy, Retryable retryable) { + this.retryCount++; } } diff --git a/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/CohereTestConfiguration.java b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/CohereTestConfiguration.java index 526cefe0344..3f2173f7d93 100644 --- a/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/CohereTestConfiguration.java +++ b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/CohereTestConfiguration.java @@ -45,7 +45,7 @@ public CohereApi cohereApi() { } @Bean - public CohereChatModel mistralAiChatModel(CohereApi api) { + public CohereChatModel cohereChatModel(CohereApi api) { return CohereChatModel.builder() .cohereApi(api) .defaultOptions(CohereChatOptions.builder().model(CohereApi.ChatModel.COMMAND_A.getValue()).build()) @@ -53,7 +53,7 @@ public CohereChatModel mistralAiChatModel(CohereApi api) { } @Bean - public CohereEmbeddingModel mistralAiEmbeddingModel(CohereApi api) { + public CohereEmbeddingModel cohereEmbeddingModel(CohereApi api) { return CohereEmbeddingModel.builder().cohereApi(api).build(); } diff --git a/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/aot/CohereRuntimeHintsTests.java b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/aot/CohereRuntimeHintsTests.java index df2d128e201..065edf04eeb 100644 --- a/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/aot/CohereRuntimeHintsTests.java +++ b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/aot/CohereRuntimeHintsTests.java @@ -16,16 +16,17 @@ package org.springframework.ai.cohere.aot; +import java.util.HashSet; +import java.util.Set; + import org.junit.jupiter.api.Test; + import org.springframework.ai.cohere.api.CohereApi; import org.springframework.ai.cohere.chat.CohereChatOptions; import org.springframework.ai.cohere.embedding.CohereEmbeddingOptions; import org.springframework.aot.hint.RuntimeHints; import org.springframework.aot.hint.TypeReference; -import java.util.HashSet; -import java.util.Set; - import static org.assertj.core.api.AssertionsForClassTypes.assertThat; import static org.assertj.core.api.AssertionsForClassTypes.assertThatCode; import static org.springframework.ai.aot.AiRuntimeHints.findJsonAnnotatedClassesInPackage; diff --git a/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/api/CohereApiIT.java b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/api/CohereApiIT.java index ee251a6fc20..656f268ad36 100644 --- a/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/api/CohereApiIT.java +++ b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/api/CohereApiIT.java @@ -1,20 +1,36 @@ +/* + * Copyright 2023-2025 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.cohere.api; +import java.util.List; + import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; -import org.springframework.ai.cohere.CohereTestConfiguration; -import org.springframework.ai.cohere.testutils.AbstractIT; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.http.ResponseEntity; +import reactor.core.publisher.Flux; +import org.springframework.ai.cohere.CohereTestConfiguration; import org.springframework.ai.cohere.api.CohereApi.ChatCompletion; import org.springframework.ai.cohere.api.CohereApi.ChatCompletionMessage; import org.springframework.ai.cohere.api.CohereApi.ChatCompletionMessage.Role; import org.springframework.ai.cohere.api.CohereApi.ChatCompletionRequest; -import reactor.core.publisher.Flux; - -import java.util.List; +import org.springframework.ai.cohere.testutils.AbstractIT; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.ResponseEntity; import static org.assertj.core.api.Assertions.assertThat; diff --git a/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/api/tool/CohereApiToolFunctionCallIT.java b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/api/tool/CohereApiToolFunctionCallIT.java index dfee5c31c04..250f78d0bca 100644 --- a/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/api/tool/CohereApiToolFunctionCallIT.java +++ b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/api/tool/CohereApiToolFunctionCallIT.java @@ -16,12 +16,16 @@ package org.springframework.ai.cohere.api.tool; +import java.util.ArrayList; +import java.util.List; + import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; 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.cohere.api.CohereApi; import org.springframework.ai.cohere.api.CohereApi.ChatCompletion; import org.springframework.ai.cohere.api.CohereApi.ChatCompletionMessage; @@ -34,9 +38,6 @@ import org.springframework.http.ResponseEntity; import org.springframework.util.ObjectUtils; -import java.util.ArrayList; -import java.util.List; - import static org.assertj.core.api.Assertions.assertThat; /** diff --git a/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/api/tool/MockWeatherService.java b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/api/tool/MockWeatherService.java index b29a16e1a79..c0b96608a7d 100644 --- a/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/api/tool/MockWeatherService.java +++ b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/api/tool/MockWeatherService.java @@ -16,14 +16,14 @@ package org.springframework.ai.cohere.api.tool; +import java.util.function.Function; + import com.fasterxml.jackson.annotation.JsonClassDescription; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude.Include; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonPropertyDescription; -import java.util.function.Function; - public class MockWeatherService implements Function { @Override diff --git a/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/api/tool/PaymentStatusFunctionCallingIT.java b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/api/tool/PaymentStatusFunctionCallingIT.java index 378ed3c2b89..2422154b458 100644 --- a/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/api/tool/PaymentStatusFunctionCallingIT.java +++ b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/api/tool/PaymentStatusFunctionCallingIT.java @@ -16,6 +16,11 @@ package org.springframework.ai.cohere.api.tool; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; @@ -23,6 +28,7 @@ import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; + import org.springframework.ai.cohere.api.CohereApi; import org.springframework.ai.cohere.api.CohereApi.ChatCompletion; import org.springframework.ai.cohere.api.CohereApi.ChatCompletionMessage; @@ -34,11 +40,6 @@ import org.springframework.ai.cohere.api.CohereApi.FunctionTool.Type; import org.springframework.http.ResponseEntity; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.function.Function; - import static org.assertj.core.api.Assertions.assertThat; @EnabledIfEnvironmentVariable(named = "COHERE_API_KEY", matches = ".+") diff --git a/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/chat/CohereChatClientIT.java b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/chat/CohereChatClientIT.java index c7a4449ad97..7eedb9194cf 100644 --- a/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/chat/CohereChatClientIT.java +++ b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/chat/CohereChatClientIT.java @@ -16,21 +16,28 @@ package org.springframework.ai.cohere.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 reactor.core.publisher.Flux; + import org.springframework.ai.chat.client.ChatClient; import org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor; import org.springframework.ai.chat.messages.UserMessage; import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.cohere.CohereTestConfiguration; +import org.springframework.ai.cohere.api.CohereApi; +import org.springframework.ai.cohere.api.CohereApi.ChatCompletionRequest.ToolChoice; import org.springframework.ai.cohere.api.tool.MockWeatherService; import org.springframework.ai.cohere.testutils.AbstractIT; import org.springframework.ai.converter.BeanOutputConverter; import org.springframework.ai.converter.ListOutputConverter; -import org.springframework.ai.cohere.api.CohereApi; -import org.springframework.ai.cohere.api.CohereApi.ChatCompletionRequest.ToolChoice; import org.springframework.ai.test.CurlyBracketEscaper; import org.springframework.ai.tool.function.FunctionToolCallback; import org.springframework.beans.factory.annotation.Value; @@ -38,12 +45,6 @@ import org.springframework.core.ParameterizedTypeReference; import org.springframework.core.convert.support.DefaultConversionService; import org.springframework.core.io.Resource; -import reactor.core.publisher.Flux; - -import java.util.Arrays; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; import static org.assertj.core.api.Assertions.assertThat; diff --git a/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/chat/CohereChatCompletionRequestTests.java b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/chat/CohereChatCompletionRequestTests.java index 6c834149e5b..80dda662b01 100644 --- a/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/chat/CohereChatCompletionRequestTests.java +++ b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/chat/CohereChatCompletionRequestTests.java @@ -16,21 +16,28 @@ package org.springframework.ai.cohere.chat; +import java.net.URI; +import java.util.List; +import java.util.Map; + import org.junit.jupiter.api.Test; -import org.springframework.ai.chat.messages.*; + +import org.springframework.ai.chat.messages.AbstractMessage; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.MessageType; +import org.springframework.ai.chat.messages.SystemMessage; +import org.springframework.ai.chat.messages.ToolResponseMessage; +import org.springframework.ai.chat.messages.UserMessage; import org.springframework.ai.chat.prompt.Prompt; -import org.springframework.ai.content.Media; import org.springframework.ai.cohere.api.CohereApi; import org.springframework.ai.cohere.api.CohereApi.ChatCompletionMessage; +import org.springframework.ai.content.Media; import org.springframework.ai.model.tool.ToolCallingChatOptions; import org.springframework.ai.tool.ToolCallback; import org.springframework.ai.tool.definition.DefaultToolDefinition; import org.springframework.ai.tool.definition.ToolDefinition; -import java.net.URI; -import java.util.List; -import java.util.Map; - import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; diff --git a/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/chat/CohereChatModelIT.java b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/chat/CohereChatModelIT.java index 6a88fee13ec..18bc37c1231 100644 --- a/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/chat/CohereChatModelIT.java +++ b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/chat/CohereChatModelIT.java @@ -16,10 +16,19 @@ package org.springframework.ai.cohere.chat; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.UUID; +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 reactor.core.publisher.Flux; + import org.springframework.ai.chat.memory.ChatMemory; import org.springframework.ai.chat.memory.MessageWindowChatMemory; import org.springframework.ai.chat.messages.AssistantMessage; @@ -33,12 +42,12 @@ import org.springframework.ai.chat.prompt.PromptTemplate; import org.springframework.ai.chat.prompt.SystemPromptTemplate; import org.springframework.ai.cohere.CohereTestConfiguration; +import org.springframework.ai.cohere.api.CohereApi; import org.springframework.ai.cohere.api.tool.MockWeatherService; import org.springframework.ai.cohere.testutils.AbstractIT; import org.springframework.ai.converter.BeanOutputConverter; import org.springframework.ai.converter.ListOutputConverter; import org.springframework.ai.converter.MapOutputConverter; -import org.springframework.ai.cohere.api.CohereApi; import org.springframework.ai.model.tool.DefaultToolCallingManager; import org.springframework.ai.model.tool.ToolCallingChatOptions; import org.springframework.ai.model.tool.ToolCallingManager; @@ -48,14 +57,6 @@ import org.springframework.ai.tool.function.FunctionToolCallback; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.core.convert.support.DefaultConversionService; -import reactor.core.publisher.Flux; - -import java.util.UUID; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; import static org.assertj.core.api.Assertions.assertThat; diff --git a/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/chat/CohereChatModelObservationIT.java b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/chat/CohereChatModelObservationIT.java index a4e71147c0d..8d5e72ee331 100644 --- a/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/chat/CohereChatModelObservationIT.java +++ b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/chat/CohereChatModelObservationIT.java @@ -16,12 +16,16 @@ package org.springframework.ai.cohere.chat; +import java.util.List; + import io.micrometer.common.KeyValue; import io.micrometer.observation.tck.TestObservationRegistry; import io.micrometer.observation.tck.TestObservationRegistryAssert; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import reactor.core.publisher.Flux; + import org.springframework.ai.chat.metadata.ChatResponseMetadata; import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.chat.observation.DefaultChatModelObservationConvention; @@ -33,11 +37,8 @@ import org.springframework.boot.SpringBootConfiguration; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.context.annotation.Bean; -import org.springframework.retry.support.RetryTemplate; +import org.springframework.core.retry.RetryTemplate; import org.springframework.util.StringUtils; -import reactor.core.publisher.Flux; - -import java.util.List; import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.ai.chat.observation.ChatModelObservationDocumentation.HighCardinalityKeyNames; @@ -174,7 +175,7 @@ public CohereChatModel cohereChatModel(CohereApi cohereApi, TestObservationRegis return CohereChatModel.builder() .cohereApi(cohereApi) .defaultOptions(CohereChatOptions.builder().build()) - .retryTemplate(RetryTemplate.defaultInstance()) + .retryTemplate(new RetryTemplate()) .observationRegistry(observationRegistry) .build(); } diff --git a/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/chat/CohereChatOptionsTests.java b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/chat/CohereChatOptionsTests.java index fd0a202fec7..eb645d6a8ee 100644 --- a/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/chat/CohereChatOptionsTests.java +++ b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/chat/CohereChatOptionsTests.java @@ -16,13 +16,14 @@ package org.springframework.ai.cohere.chat; -import org.junit.jupiter.api.Test; -import org.springframework.ai.cohere.api.CohereApi; - import java.util.Collections; import java.util.List; import java.util.Map; +import org.junit.jupiter.api.Test; + +import org.springframework.ai.cohere.api.CohereApi; + import static org.assertj.core.api.Assertions.assertThat; /** diff --git a/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/embedding/CohereEmbeddingIT.java b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/embedding/CohereEmbeddingIT.java index d1e46f97da4..fc68d35853c 100644 --- a/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/embedding/CohereEmbeddingIT.java +++ b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/embedding/CohereEmbeddingIT.java @@ -16,18 +16,19 @@ package org.springframework.ai.cohere.embedding; +import java.util.List; + import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; + import org.springframework.ai.cohere.CohereTestConfiguration; import org.springframework.ai.cohere.api.CohereApi; import org.springframework.ai.embedding.EmbeddingRequest; 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; /** diff --git a/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/embedding/CohereEmbeddingModelObservationIT.java b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/embedding/CohereEmbeddingModelObservationIT.java index d062374c7be..cf2226e265b 100644 --- a/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/embedding/CohereEmbeddingModelObservationIT.java +++ b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/embedding/CohereEmbeddingModelObservationIT.java @@ -16,24 +16,25 @@ package org.springframework.ai.cohere.embedding; +import java.util.List; + import io.micrometer.observation.tck.TestObservationRegistry; import io.micrometer.observation.tck.TestObservationRegistryAssert; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; + +import org.springframework.ai.cohere.api.CohereApi; import org.springframework.ai.embedding.EmbeddingRequest; import org.springframework.ai.embedding.EmbeddingResponse; import org.springframework.ai.embedding.EmbeddingResponseMetadata; import org.springframework.ai.embedding.observation.DefaultEmbeddingModelObservationConvention; -import org.springframework.ai.cohere.api.CohereApi; import org.springframework.ai.observation.conventions.AiOperationType; import org.springframework.ai.observation.conventions.AiProvider; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.SpringBootConfiguration; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.context.annotation.Bean; -import org.springframework.retry.support.RetryTemplate; - -import java.util.List; +import org.springframework.core.retry.RetryTemplate; import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.ai.embedding.observation.EmbeddingModelObservationDocumentation.HighCardinalityKeyNames; @@ -107,7 +108,7 @@ public CohereEmbeddingModel cohereEmbeddingModel(CohereApi cohereApi, return CohereEmbeddingModel.builder() .cohereApi(cohereApi) .options(CohereEmbeddingOptions.builder().build()) - .retryTemplate(RetryTemplate.defaultInstance()) + .retryTemplate(new RetryTemplate()) .observationRegistry(observationRegistry) .build(); } diff --git a/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/embedding/CohereEmbeddingModelTests.java b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/embedding/CohereEmbeddingModelTests.java index 9a9ab9e308e..56f29dab3d4 100644 --- a/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/embedding/CohereEmbeddingModelTests.java +++ b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/embedding/CohereEmbeddingModelTests.java @@ -16,15 +16,16 @@ package org.springframework.ai.cohere.embedding; +import java.util.List; + import org.junit.jupiter.api.Test; import org.mockito.Mockito; + import org.springframework.ai.cohere.api.CohereApi; import org.springframework.ai.document.MetadataMode; import org.springframework.ai.retry.RetryUtils; import org.springframework.http.ResponseEntity; -import java.util.List; - import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; diff --git a/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/testutils/AbstractIT.java b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/testutils/AbstractIT.java index 0dc98ae1805..261dcd9231f 100644 --- a/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/testutils/AbstractIT.java +++ b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/testutils/AbstractIT.java @@ -16,8 +16,12 @@ package org.springframework.ai.cohere.testutils; +import java.util.List; +import java.util.Map; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; + import org.springframework.ai.chat.messages.Message; import org.springframework.ai.chat.messages.SystemMessage; import org.springframework.ai.chat.model.ChatModel; @@ -30,9 +34,6 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.core.io.Resource; -import java.util.List; -import java.util.Map; - import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.fail; From cf47b67cf59f060afeebba42d019cd3cff6b25a7 Mon Sep 17 00:00:00 2001 From: Ricken Bazolo Date: Sat, 22 Nov 2025 04:05:33 +0100 Subject: [PATCH 15/18] added cohere support :: documentation Signed-off-by: Ricken Bazolo Signed-off-by: ricken07 --- .../src/main/antora/modules/ROOT/nav.adoc | 2 + .../ROOT/pages/api/chat/cohere-chat.adoc | 339 ++++++++++++++++++ .../api/embeddings/cohere-embeddings.adoc | 310 ++++++++++++++++ 3 files changed, 651 insertions(+) create mode 100644 spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/cohere-chat.adoc create mode 100644 spring-ai-docs/src/main/antora/modules/ROOT/pages/api/embeddings/cohere-embeddings.adoc diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/nav.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/nav.adoc index d4b40d8dd3f..d485152ee97 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/nav.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/nav.adoc @@ -17,6 +17,7 @@ **** xref:api/chat/bedrock-converse.adoc[Amazon Bedrock Converse] **** xref:api/chat/anthropic-chat.adoc[Anthropic] **** xref:api/chat/azure-openai-chat.adoc[Azure OpenAI] +**** xref:api/chat/cohere-chat.adoc[Cohere] **** xref:api/chat/deepseek-chat.adoc[DeepSeek] **** xref:api/chat/dmr-chat.adoc[Docker Model Runner] **** Google @@ -56,6 +57,7 @@ ***** xref:api/embeddings/vertexai-embeddings-text.adoc[Text Embedding] ***** xref:api/embeddings/vertexai-embeddings-multimodal.adoc[Multimodal Embedding] **** xref:api/embeddings/zhipuai-embeddings.adoc[ZhiPu AI] +**** xref:api/embeddings/cohere-embeddings.adoc[Cohere] *** xref:api/imageclient.adoc[Image Models] **** xref:api/image/azure-openai-image.adoc[Azure OpenAI] diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/cohere-chat.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/cohere-chat.adoc new file mode 100644 index 00000000000..8bd94879ea5 --- /dev/null +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/cohere-chat.adoc @@ -0,0 +1,339 @@ += Cohere Chat + +Spring AI supports the various AI language models from Cohere. You can interact with Cohere language models and create multilingual conversational assistants based on Cohere's powerful models. + +== Prerequisites + +You will need to create an API key with Cohere to access Cohere language models. + +Create an account at https://dashboard.cohere.com/welcome/register[Cohere registration page] and generate the token on the https://dashboard.cohere.com/api-keys[API Keys page]. + +The Spring AI project defines a configuration property named `spring.ai.cohere.api-key` that you should set to the value of the API Key obtained from dashboard.cohere.com. + +You can set this configuration property in your `application.properties` file: + +[source,properties] +---- +spring.ai.cohere.api-key= +---- + +Alternatively, you can set this as an environment variable: + +[source,bash] +---- +export COHERE_API_KEY= +---- + +=== Add Repositories and BOM + +Spring AI artifacts are published in Maven Central and Spring Snapshot repositories. +Refer to the xref:getting-started.adoc#artifact-repositories[Artifact Repositories] section to add these repositories to your build system. + +To help with dependency management, Spring AI provides a BOM (bill of materials) to ensure that a consistent version of Spring AI is used throughout the entire project. Refer to the xref:getting-started.adoc#dependency-management[Dependency Management] section to add the Spring AI BOM to your build system. + +== Auto-configuration + +[NOTE] +==== +There has been a significant change in the Spring AI auto-configuration, starter modules' artifact names. +Please refer to the https://docs.spring.io/spring-ai/reference/upgrade-notes.html[upgrade notes] for more information. +==== + +Spring AI provides Spring Boot auto-configuration for the Cohere Chat Client. +To enable it add the following dependency to your project's Maven `pom.xml` file: + +[source, xml] +---- + + org.springframework.ai + spring-ai-starter-model-cohere + +---- + +or to your Gradle `build.gradle` build file. + +[source,groovy] +---- +dependencies { + implementation 'org.springframework.ai:spring-ai-starter-model-cohere' +} +---- + +TIP: Refer to the xref:getting-started.adoc#dependency-management[Dependency Management] section to add the Spring AI BOM to your build file. + +=== Chat Properties + +==== Retry Properties + +The prefix `spring.ai.retry` is used as the property prefix that lets you configure the retry mechanism for the Cohere chat model. + +[cols="3,5,1", stripes=even] +|==== +| Property | Description | Default + +| spring.ai.retry.max-attempts | Maximum number of retry attempts. | 10 +| spring.ai.retry.backoff.initial-interval | Initial sleep duration for the exponential backoff policy. | 2 sec. +| spring.ai.retry.backoff.multiplier | Backoff interval multiplier. | 5 +| spring.ai.retry.backoff.max-interval | Maximum backoff duration. | 3 min. +| spring.ai.retry.on-client-errors | If false, throw a NonTransientAiException, and do not attempt retry for `4xx` client error codes | false +| spring.ai.retry.exclude-on-http-codes | List of HTTP status codes that should not trigger a retry (e.g. to throw NonTransientAiException). | empty +| spring.ai.retry.on-http-codes | List of HTTP status codes that should trigger a retry (e.g. to throw TransientAiException). | empty +|==== + +==== Connection Properties + +The prefix `spring.ai.cohere` is used as the property prefix that lets you connect to Cohere. + +[cols="3,5,1", stripes=even] +|==== +| Property | Description | Default + +| spring.ai.cohere.base-url | The URL to connect to | https://api.cohere.com +| spring.ai.cohere.api-key | The API Key | - +|==== + +==== Configuration Properties + +[NOTE] +==== +Enabling and disabling of the chat auto-configurations are now configured via top level properties with the prefix `spring.ai.model.chat`. + +To enable, spring.ai.model.chat=cohere (It is enabled by default) + +To disable, spring.ai.model.chat=none (or any value which doesn't match cohere) + +This change is done to allow configuration of multiple models. +==== + +The prefix `spring.ai.cohere.chat` is the property prefix that lets you configure the chat model implementation for Cohere. + +[cols="3,5,1", stripes=even] +|==== +| Property | Description | Default + +| spring.ai.cohere.chat.enabled (Removed and no longer valid) | Enable Cohere chat model. | true +| spring.ai.model.chat | Enable Cohere chat model. | cohere +| spring.ai.cohere.chat.base-url | Optional override for the `spring.ai.cohere.base-url` property to provide chat-specific URL. | - +| spring.ai.cohere.chat.api-key | Optional override for the `spring.ai.cohere.api-key` to provide chat-specific API Key. | - +| spring.ai.cohere.chat.options.model | This is the Cohere Chat model to use | `command-r7b-12-2024` (see available models below) +| spring.ai.cohere.chat.options.temperature | The sampling temperature to use that controls the apparent creativity of generated completions. Higher values will make output more random while lower values will make results more focused and deterministic. It is not recommended to modify `temperature` and `p` for the same completions request as the interaction of these two settings is difficult to predict. | 0.3 +| spring.ai.cohere.chat.options.max-tokens | The maximum number of tokens to generate in the chat completion. The total length of input tokens and generated tokens is limited by the model's context length. | - +| spring.ai.cohere.chat.options.p | Ensures that only the most likely tokens, with total probability mass of p, are considered for generation at each step. If both k and p are enabled, p acts after k. min value of 0.01, max value of 0.99. | 1.0 +| spring.ai.cohere.chat.options.k | Ensures that only the top k most likely tokens are considered for generation at each step. When k is set to 0, k-sampling is disabled. min value of 0, max value of 500. | 0 +| spring.ai.cohere.chat.options.frequency-penalty | Used to reduce repetitiveness of generated tokens. The higher the value, the stronger a penalty is applied to previously present tokens, proportional to how many times they have already appeared in the prompt or prior generation. Min value of 0.0, max value of 1.0. | 0.0 +| spring.ai.cohere.chat.options.presence-penalty | Used to reduce repetitiveness of generated tokens. Similar to frequency_penalty, except that this penalty is applied equally to all tokens that have already appeared, regardless of their exact frequencies. Min value of 0.0, max value of 1.0. | 0.0 +| spring.ai.cohere.chat.options.seed | If specified, the backend will make a best effort to sample tokens deterministically, such that repeated requests with the same seed and parameters should return the same result. | - +| spring.ai.cohere.chat.options.stop-sequences | A list of up to 5 strings that the model will use to stop generation. If the model generates a string that matches any of the strings in the list, it will stop generating tokens. | - +| spring.ai.cohere.chat.options.response-format | An object specifying the format that the model must output. Setting to `{ "type": "json_object" }` enables JSON mode, which guarantees the message the model generates is valid JSON.| - +| spring.ai.cohere.chat.options.safety-mode | Used to select the safety instruction inserted into the prompt. Can be OFF, CONTEXTUAL, or STRICT. When OFF is specified, the safety instruction will be omitted. | CONTEXTUAL +| spring.ai.cohere.chat.options.logprobs | When set to true, the log probabilities of the generated tokens will be included in the response. | false +| spring.ai.cohere.chat.options.strict-tools | When enabled, tool calls are validated against the tool JSON schemas. | - +| spring.ai.cohere.chat.options.tools | A list of tools the model may call. Currently, only functions are supported as a tool. Use this to provide a list of functions the model may generate JSON inputs for. | - +| spring.ai.cohere.chat.options.tool-choice | Controls which (if any) function is called by the model. `none` means the model will not call a function and instead generates a message. `required` means the model can pick between generating a message or calling a function. Specifying a particular function via `{"type: "function", "function": {"name": "my_function"}}` forces the model to call that function. `required` is the default when no functions are present. `required` is the default if functions are present. | - +| spring.ai.cohere.chat.options.tool-names | List of tools, identified by their names, to enable for function calling in a single prompt request. Tools with those names must exist in the ToolCallback registry. | - +| spring.ai.cohere.chat.options.tool-callbacks | Tool Callbacks to register with the ChatModel. | - +| spring.ai.cohere.chat.options.internal-tool-execution-enabled | If false, the Spring AI will not handle the tool calls internally, but will proxy them to the client. Then it is the client's responsibility to handle the tool calls, dispatch them to the appropriate function, and return the results. If true (the default), the Spring AI will handle the function calls internally. Applicable only for chat models with function calling support | true +|==== + +NOTE: You can override the common `spring.ai.cohere.base-url` and `spring.ai.cohere.api-key` for the `ChatModel` and `EmbeddingModel` implementations. +The `spring.ai.cohere.chat.base-url` and `spring.ai.cohere.chat.api-key` properties, if set, take precedence over the common properties. +This is useful if you want to use different Cohere accounts for different models and different model endpoints. + +TIP: All properties prefixed with `spring.ai.cohere.chat.options` can be overridden at runtime by adding request-specific <> to the `Prompt` call. + +== Available Models + +Cohere provides several chat models, each optimized for different use cases: + +[cols="2,1,4", stripes=even] +|==== +| Model | Context Length | Description + +| `command-a-03-2025` +| 128K tokens +| Latest flagship model with enhanced reasoning capabilities. Best overall performance for complex tasks. + +| `command-a-reasoning-08-2025` +| 128K tokens +| Specialized model optimized for reasoning tasks, mathematical problem-solving, and logical deduction. + +| `command-a-translate-08-2025` +| 128K tokens +| Optimized for translation tasks across multiple languages. Provides high-quality translations. + +| `command-a-vision-07-2025` +| 128K tokens +| Multimodal model with vision capabilities. Can process and understand images along with text. + +| `command-r7b-12-2024` +| 128K tokens +| Lightweight 7 billion parameter model. Faster and more cost-effective while maintaining good quality. Default model. + +| `command-r-plus-08-2024` +| 128K tokens +| Enhanced version of Command R with improved performance and multilingual capabilities. + +| `command-r-08-2024` +| 128K tokens +| General-purpose model with strong multilingual support and retrieval-augmented generation capabilities. +|==== + + +== Runtime Options [[chat-options]] + +The link:https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/chat/CohereChatOptions.java[CohereChatOptions.java] provides model configurations, such as the model to use, the temperature, the frequency penalty, etc. + +On start-up, the default options can be configured with the `CohereChatModel(api, options)` constructor or the `spring.ai.cohere.chat.options.*` properties. + +At run-time, you can override the default options by adding new, request-specific options to the `Prompt` call. +For example, to override the default model and temperature for a specific request: + +[source,java] +---- +ChatResponse response = chatModel.call( + new Prompt( + "Generate the names of 5 famous pirates.", + CohereChatOptions.builder() + .model(CohereApi.ChatModel.COMMAND_A.getName()) + .temperature(0.5) + .build() + )); +---- + +TIP: In addition to the model specific link:https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/chat/CohereChatOptions.java[CohereChatOptions] you can use a portable link:https://github.com/spring-projects/spring-ai/blob/main/spring-ai-model/src/main/java/org/springframework/ai/chat/prompt/ChatOptions.java[ChatOptions] instance, created with link:https://github.com/spring-projects/spring-ai/blob/main/spring-ai-model/src/main/java/org/springframework/ai/chat/prompt/DefaultChatOptionsBuilder.java[ChatOptions#builder()]. + +== Function Calling + +You can register custom Java functions with the `CohereChatModel` and have the Cohere model intelligently choose to output a JSON object containing arguments to call one or many of the registered functions. +This is a powerful technique to connect the LLM capabilities with external tools and APIs. +Read more about xref:api/tools.adoc[Tool Calling]. + +== Multimodal + +Multimodality refers to a model's ability to simultaneously understand and process information from various sources, including text, images, audio, and other data formats. +Cohere supports text and vision modalities. + +=== Vision + +Cohere models that offer vision multimodal support include `command-a-vision-07-2025`. +Refer to the link:https://docs.cohere.com/docs/vision[Vision] guide for more information. + +The Cohere link:https://docs.cohere.com/reference/chat[Chat API] can incorporate a list of base64-encoded images or image urls with the message. +Spring AI's link:https://github.com/spring-projects/spring-ai/blob/main/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/messages/Message.java[Message] interface facilitates multimodal AI models by introducing the link:https://github.com/spring-projects/spring-ai/blob/main/spring-ai-commons/src/main/java/org/springframework/ai/content/Media.java[Media] type. +This type encompasses data and details regarding media attachments in messages, utilizing Spring's `org.springframework.util.MimeType` and a `org.springframework.core.io.Resource` for the raw media data. + +Below is a code example illustrating the fusion of user text with an image: + +[source,java] +---- +var imageResource = new ClassPathResource("/multimodal.test.png"); + +var userMessage = new UserMessage("Explain what do you see on this picture?", + new Media(MimeTypeUtils.IMAGE_PNG, imageResource)); + +ChatResponse response = chatModel.call(new Prompt(userMessage, + ChatOptions.builder().model(CohereApi.ChatModel.COMMAND_A_VISION.getName()).build())); +---- + +TIP: You can pass multiple images as well. + +== Sample Controller (Auto-configuration) + +https://start.spring.io/[Create] a new Spring Boot project and add the `spring-ai-starter-model-cohere` to your pom (or gradle) dependencies. + +Add a `application.properties` file under the `src/main/resources` directory to enable and configure the Cohere chat model: + +[source,application.properties] +---- +spring.ai.cohere.api-key=YOUR_API_KEY +spring.ai.cohere.chat.options.model=command-r7b-12-2024 +spring.ai.cohere.chat.options.temperature=0.7 +---- + +TIP: Replace the `api-key` with your Cohere credentials. + +This will create a `CohereChatModel` implementation that you can inject into your classes. +Here is an example of a simple `@RestController` class that uses the chat model for text generations. + +[source,java] +---- +@RestController +public class ChatController { + + private final CohereChatModel chatModel; + + @Autowired + public ChatController(CohereChatModel chatModel) { + this.chatModel = chatModel; + } + + @GetMapping("/ai/generate") + public Map generate(@RequestParam(value = "message", defaultValue = "Tell me a joke") String message) { + return Map.of("generation", this.chatModel.call(message)); + } + + @GetMapping("/ai/generateStream") + public Flux generateStream(@RequestParam(value = "message", defaultValue = "Tell me a joke") String message) { + var prompt = new Prompt(new UserMessage(message)); + return this.chatModel.stream(prompt); + } +} +---- + +== Manual Configuration + +The link:https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/chat/CohereChatModel.java[CohereChatModel] implements the `ChatModel` and `StreamingChatModel` and uses the <> to connect to the Cohere service. + +Add the `spring-ai-cohere` dependency to your project's Maven `pom.xml` file: + +[source, xml] +---- + + org.springframework.ai + spring-ai-cohere + +---- + +or to your Gradle `build.gradle` build file. + +[source,groovy] +---- +dependencies { + implementation 'org.springframework.ai:spring-ai-cohere' +} +---- + +TIP: Refer to the xref:getting-started.adoc#dependency-management[Dependency Management] section to add the Spring AI BOM to your build file. + +Next, create a `CohereChatModel` and use it for text generations: + +[source,java] +---- +var cohereApi = new CohereApi(System.getenv("COHERE_API_KEY")); +var chatModel = new CohereChatModel(cohereApi, CohereChatOptions.builder() + .model(CohereApi.ChatModel.COMMAND_A.getName()) + .temperature(0.4) + .build()); + +ChatResponse response = chatModel.call(new Prompt("Generate the names of 5 famous pirates.")); +---- + +=== Low-level CohereApi Client [[low-level-api]] + +The link:https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/api/CohereApi.java[CohereApi] provides a lightweight Java client for link:https://docs.cohere.com/reference/chat[Cohere API]. + +Here is a simple snippet showing how to use the API programmatically: + +[source,java] +---- +CohereApi cohereApi = new CohereApi(System.getenv("COHERE_API_KEY")); +ChatCompletionMessage message = new ChatCompletionMessage("Hello world", Role.USER); +ResponseEntity response = cohereApi.chatCompletionEntity( + new ChatCompletionRequest(List.of(message), CohereApi.ChatModel.COMMAND_A.getName(), 0.8, false)); +---- + +==== CohereApi Samples + +* The link:https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/api/CohereApiIT.java[CohereApiIT.java] tests provide some general examples of how to use the lightweight library. + +* The link:https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/chat/CohereChatModelIT.java[CohereChatModelIT.java] tests show examples of using function calling and streaming. diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/embeddings/cohere-embeddings.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/embeddings/cohere-embeddings.adoc new file mode 100644 index 00000000000..37c50f86b75 --- /dev/null +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/embeddings/cohere-embeddings.adoc @@ -0,0 +1,310 @@ += Cohere Embeddings + +Spring AI supports Cohere's text embedding models. +Embeddings are vectorial representations of text that capture the semantic meaning of paragraphs through their position in a high dimensional vector space. Cohere Embeddings API offers cutting-edge, state-of-the-art embeddings for text, which can be used for many NLP tasks. + +== Available Models + +Cohere provides several embedding models, each optimized for different use cases: + +[cols="2,2,1,4", stripes=even] +|==== +| Model | Dimensions | Use Case | Description + +| `embed-v4` +| 1024 +| General text +| Latest general-purpose embedding model suitable for semantic search, clustering, and text similarity tasks. Offers improved performance and multilingual support. + +| `embed-english-v3.0` +| 1024 +| English text +| Optimized for English language content. Ideal for semantic search and text classification tasks with English documents. + +| `embed-multilingual-v3.0` +| 1024 +| Multilingual text +| Supports over 100 languages. Perfect for applications requiring multilingual semantic search and text similarity. + +| `embed-english-light-v3.0` +| 384 +| English text (lightweight) +| Lightweight model for English content. Faster inference with reduced memory footprint while maintaining good accuracy. + +| `embed-multilingual-light-v3.0` +| 384 +| Multilingual text (lightweight) +| Lightweight multilingual model. Balances performance and resource usage for multilingual applications. +|==== + +When choosing a model: + +* Use `embed-v4` for the latest features and best overall performance +* Use `embed-english-v3.0` for high-quality English-only embeddings +* Use `embed-multilingual-v3.0` when working with multiple languages +* Use the "light" variants when you need faster inference or have resource constraints + +== Prerequisites + +You will need to create an API key with Cohere to access Cohere embedding models. + +Create an account at https://dashboard.cohere.com/welcome/register[Cohere registration page] and generate the token on the https://dashboard.cohere.com/api-keys[API Keys page]. + +The Spring AI project defines a configuration property named `spring.ai.cohere.api-key` that you should set to the value of the API Key obtained from dashboard.cohere.com. + +You can set this configuration property in your `application.properties` file: + +[source,properties] +---- +spring.ai.cohere.api-key= +---- + +For enhanced security when handling sensitive information like API keys, you can use Spring Expression Language (SpEL) to reference an environment variable: + +[source,yaml] +---- +# In application.yml +spring: + ai: + cohere: + api-key: ${COHERE_API_KEY} +---- + +[source,bash] +---- +# In your environment or .env file +export COHERE_API_KEY= +---- + +You can also set this configuration programmatically in your application code: + +[source,java] +---- +// Retrieve API key from a secure source or environment variable +String apiKey = System.getenv("COHERE_API_KEY"); +---- + +=== Add Repositories and BOM + +Spring AI artifacts are published in Maven Central and Spring Snapshot repositories. +Refer to the xref:getting-started.adoc#artifact-repositories[Artifact Repositories] section to add these repositories to your build system. + +To help with dependency management, Spring AI provides a BOM (bill of materials) to ensure that a consistent version of Spring AI is used throughout the entire project. Refer to the xref:getting-started.adoc#dependency-management[Dependency Management] section to add the Spring AI BOM to your build system. + + +== Auto-configuration + +[NOTE] +==== +There has been a significant change in the Spring AI auto-configuration, starter modules' artifact names. +Please refer to the https://docs.spring.io/spring-ai/reference/upgrade-notes.html[upgrade notes] for more information. +==== + +Spring AI provides Spring Boot auto-configuration for the Cohere Embedding Model. +To enable it add the following dependency to your project's Maven `pom.xml` file: + +[source, xml] +---- + + org.springframework.ai + spring-ai-starter-model-cohere + +---- + +or to your Gradle `build.gradle` build file. + +[source,groovy] +---- +dependencies { + implementation 'org.springframework.ai:spring-ai-starter-model-cohere' +} +---- + +TIP: Refer to the xref:getting-started.adoc#dependency-management[Dependency Management] section to add the Spring AI BOM to your build file. + +=== Embedding Properties + +==== Retry Properties + +The prefix `spring.ai.retry` is used as the property prefix that lets you configure the retry mechanism for the Cohere Embedding model. + +[cols="3,5,1", stripes=even] +|==== +| Property | Description | Default + +| spring.ai.retry.max-attempts | Maximum number of retry attempts. | 10 +| spring.ai.retry.backoff.initial-interval | Initial sleep duration for the exponential backoff policy. | 2 sec. +| spring.ai.retry.backoff.multiplier | Backoff interval multiplier. | 5 +| spring.ai.retry.backoff.max-interval | Maximum backoff duration. | 3 min. +| spring.ai.retry.on-client-errors | If false, throw a NonTransientAiException, and do not attempt retry for `4xx` client error codes | false +| spring.ai.retry.exclude-on-http-codes | List of HTTP status codes that should not trigger a retry (e.g. to throw NonTransientAiException). | empty +| spring.ai.retry.on-http-codes | List of HTTP status codes that should trigger a retry (e.g. to throw TransientAiException). | empty +|==== + +==== Connection Properties + +The prefix `spring.ai.cohere` is used as the property prefix that lets you connect to Cohere. + +[cols="3,5,1", stripes=even] +|==== +| Property | Description | Default + +| spring.ai.cohere.base-url | The URL to connect to | https://api.cohere.com +| spring.ai.cohere.api-key | The API Key | - +|==== + +==== Configuration Properties + +[NOTE] +==== +Enabling and disabling of the embedding auto-configurations are now configured via top level properties with the prefix `spring.ai.model.embedding`. + +To enable, spring.ai.model.embedding=cohere (It is enabled by default) + +To disable, spring.ai.model.embedding=none (or any value which doesn't match cohere) + +This change is done to allow configuration of multiple models. +==== + +The prefix `spring.ai.cohere.embedding` is property prefix that configures the `EmbeddingModel` implementation for Cohere. + +[cols="3,5,1", stripes=even] +|==== +| Property | Description | Default + +| spring.ai.cohere.embedding.enabled (Removed and no longer valid) | Enable Cohere embedding model. | true +| spring.ai.model.embedding | Enable Cohere embedding model. | cohere +| spring.ai.cohere.embedding.base-url | Optional overrides the spring.ai.cohere.base-url to provide embedding specific url | - +| spring.ai.cohere.embedding.api-key | Optional overrides the spring.ai.cohere.api-key to provide embedding specific api-key | - +| spring.ai.cohere.embedding.metadata-mode | Document content extraction mode. | EMBED +| spring.ai.cohere.embedding.options.model | The model to use | embed-v4 +| spring.ai.cohere.embedding.options.input-type | The type of input (search_document, search_query, classification, clustering) | classification +| spring.ai.cohere.embedding.options.embedding-types | The types of embeddings to return (float, int8, uint8, binary, ubinary) | [float] +| spring.ai.cohere.embedding.options.truncate | How to handle inputs longer than maximum token length (NONE, START, END) | - +|==== + +NOTE: You can override the common `spring.ai.cohere.base-url` and `spring.ai.cohere.api-key` for the `ChatModel` and `EmbeddingModel` implementations. +The `spring.ai.cohere.embedding.base-url` and `spring.ai.cohere.embedding.api-key` properties if set take precedence over the common properties. +Similarly, the `spring.ai.cohere.chat.base-url` and `spring.ai.cohere.chat.api-key` properties if set take precedence over the common properties. +This is useful if you want to use different Cohere accounts for different models and different model endpoints. + +TIP: All properties prefixed with `spring.ai.cohere.embedding.options` can be overridden at runtime by adding a request specific <> to the `EmbeddingRequest` call. + +== Runtime Options [[embedding-options]] + +The https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/embedding/CohereEmbeddingOptions.java[CohereEmbeddingOptions.java] provides the Cohere configurations, such as the model to use and etc. + +The default options can be configured using the `spring.ai.cohere.embedding.options` properties as well. + +At start-time use the `CohereEmbeddingModel` constructor to set the default options used for all embedding requests. +At run-time you can override the default options, using a `CohereEmbeddingOptions` instance as part of your `EmbeddingRequest`. + +For example to override the default model name and input type for a specific request: + +[source,java] +---- +// Using embed-v4 for general text embeddings +EmbeddingResponse embeddingResponse = embeddingModel.call( + new EmbeddingRequest(List.of("Hello World", "World is big and salvation is near"), + CohereEmbeddingOptions.builder() + .model("embed-v4") + .inputType(InputType.SEARCH_DOCUMENT) + .build())); + +// Using embed-multilingual-v3.0 for multilingual content +EmbeddingResponse multilingualResponse = embeddingModel.call( + new EmbeddingRequest(List.of("Bonjour le monde", "Hola mundo"), + CohereEmbeddingOptions.builder() + .model("embed-multilingual-v3.0") + .inputType(InputType.CLUSTERING) + .build())); +---- + +== Understanding Input Types + +Cohere embeddings support different input types to optimize the embeddings for specific use cases: + +* `SEARCH_DOCUMENT`: Use when embedding documents to be retrieved in a search system +* `SEARCH_QUERY`: Use when embedding search queries to match against documents +* `CLASSIFICATION`: Use for text classification tasks +* `CLUSTERING`: Use for clustering documents by similarity + +For best results in semantic search applications, use `SEARCH_DOCUMENT` for your corpus and `SEARCH_QUERY` for user queries. + +== Sample Controller + +This will create a `EmbeddingModel` implementation that you can inject into your class. +Here is an example of a simple `@Controller` class that uses the `EmbeddingModel` implementation. + +[source,application.properties] +---- +spring.ai.cohere.api-key=YOUR_API_KEY +spring.ai.cohere.embedding.options.model=embed-v4 +spring.ai.cohere.embedding.options.input-type=classification +---- + +[source,java] +---- +@RestController +public class EmbeddingController { + + private final EmbeddingModel embeddingModel; + + @Autowired + public EmbeddingController(EmbeddingModel embeddingModel) { + this.embeddingModel = embeddingModel; + } + + @GetMapping("/ai/embedding") + public Map embed(@RequestParam(value = "message", defaultValue = "Tell me a joke") String message) { + var embeddingResponse = this.embeddingModel.embedForResponse(List.of(message)); + return Map.of("embedding", embeddingResponse); + } +} +---- + +== Manual Configuration + +If you are not using Spring Boot, you can manually configure the Cohere Embedding Model. +For this add the `spring-ai-cohere` dependency to your project's Maven `pom.xml` file: +[source, xml] +---- + + org.springframework.ai + spring-ai-cohere + +---- + +or to your Gradle `build.gradle` build file. + +[source,groovy] +---- +dependencies { + implementation 'org.springframework.ai:spring-ai-cohere' +} +---- + +TIP: Refer to the xref:getting-started.adoc#dependency-management[Dependency Management] section to add the Spring AI BOM to your build file. + +NOTE: The `spring-ai-cohere` dependency provides access also to the `CohereChatModel`. +For more information about the `CohereChatModel` refer to the link:../chat/cohere-chat.html[Cohere Chat Client] section. + +Next, create a `CohereEmbeddingModel` instance and use it to compute the similarity between two input texts: + +[source,java] +---- +var cohereApi = new CohereApi(System.getenv("COHERE_API_KEY")); + +var embeddingModel = new CohereEmbeddingModel(this.cohereApi, + CohereEmbeddingOptions.builder() + .model("embed-v4") + .inputType(InputType.CLASSIFICATION) + .embeddingTypes(List.of(EmbeddingType.FLOAT)) + .build()); + +EmbeddingResponse embeddingResponse = this.embeddingModel + .embedForResponse(List.of("Hello World", "World is big and salvation is near")); +---- + +The `CohereEmbeddingOptions` provides the configuration information for the embedding requests. +The options class offers a `builder()` for easy options creation. From cf29e5be0b61603093f550f7a318bc5acd001e2d Mon Sep 17 00:00:00 2001 From: ricken07 Date: Thu, 27 Nov 2025 17:42:17 +0100 Subject: [PATCH 16/18] added cohere support :: embedding multimodal Signed-off-by: ricken07 --- ...eMultimodalEmbeddingAutoConfiguration.java | 88 ++++++++ .../CohereMultimodalEmbeddingProperties.java | 55 +++++ ...ot.autoconfigure.AutoConfiguration.imports | 1 + .../CohereAutoConfigurationIT.java | 32 +++ .../CohereModelConfigurationTests.java | 23 ++ .../ai/cohere/api/CohereApi.java | 30 ++- .../embedding/CohereEmbeddingUtils.java | 88 ++++++++ .../CohereMultimodalEmbeddingModel.java | 208 ++++++++++++++++++ .../CohereMultimodalEmbeddingOptions.java | 177 +++++++++++++++ .../ai/cohere/CohereTestConfiguration.java | 6 + .../CohereMultimodalEmbeddingIT.java | 107 +++++++++ .../src/test/resources/test.image.png | Bin 0 -> 167772 bytes 12 files changed, 810 insertions(+), 5 deletions(-) create mode 100644 auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/java/org/springframework/ai/cohere/autoconfigure/CohereMultimodalEmbeddingAutoConfiguration.java create mode 100644 auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/java/org/springframework/ai/cohere/autoconfigure/CohereMultimodalEmbeddingProperties.java create mode 100644 models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/embedding/CohereEmbeddingUtils.java create mode 100644 models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/embedding/CohereMultimodalEmbeddingModel.java create mode 100644 models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/embedding/CohereMultimodalEmbeddingOptions.java create mode 100644 models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/embedding/CohereMultimodalEmbeddingIT.java create mode 100644 models/spring-ai-cohere/src/test/resources/test.image.png diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/java/org/springframework/ai/cohere/autoconfigure/CohereMultimodalEmbeddingAutoConfiguration.java b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/java/org/springframework/ai/cohere/autoconfigure/CohereMultimodalEmbeddingAutoConfiguration.java new file mode 100644 index 00000000000..72901f91c1e --- /dev/null +++ b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/java/org/springframework/ai/cohere/autoconfigure/CohereMultimodalEmbeddingAutoConfiguration.java @@ -0,0 +1,88 @@ +/* + * Copyright 2023-2025 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.cohere.autoconfigure; + +import io.micrometer.observation.ObservationRegistry; + +import org.springframework.ai.cohere.api.CohereApi; +import org.springframework.ai.cohere.embedding.CohereMultimodalEmbeddingModel; +import org.springframework.ai.model.SpringAIModelProperties; +import org.springframework.ai.model.SpringAIModels; +import org.springframework.ai.retry.autoconfigure.SpringAiRetryAutoConfiguration; +import org.springframework.beans.factory.ObjectProvider; +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.context.properties.EnableConfigurationProperties; +import org.springframework.boot.restclient.autoconfigure.RestClientAutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.core.retry.RetryTemplate; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; +import org.springframework.web.client.ResponseErrorHandler; +import org.springframework.web.client.RestClient; + +/** + * Multimodal Embedding {@link AutoConfiguration Auto-configuration} for Cohere + * + * @author Ricken Bazolo + */ +@AutoConfiguration(after = { RestClientAutoConfiguration.class, SpringAiRetryAutoConfiguration.class }) +@EnableConfigurationProperties({ CohereCommonProperties.class, CohereMultimodalEmbeddingProperties.class }) +@ConditionalOnClass({ CohereApi.class, CohereMultimodalEmbeddingModel.class }) +@ConditionalOnProperty(name = SpringAIModelProperties.MULTI_MODAL_EMBEDDING_MODEL, havingValue = SpringAIModels.COHERE, + matchIfMissing = true) +public class CohereMultimodalEmbeddingAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + public CohereMultimodalEmbeddingModel cohereMultimodalEmbeddingModel(CohereCommonProperties commonProperties, + CohereMultimodalEmbeddingProperties embeddingProperties, + ObjectProvider restClientBuilderProvider, RetryTemplate retryTemplate, + ResponseErrorHandler responseErrorHandler, ObjectProvider observationRegistry) { + + var cohereApi = cohereApi(embeddingProperties.getApiKey(), commonProperties.getApiKey(), + embeddingProperties.getBaseUrl(), commonProperties.getBaseUrl(), + restClientBuilderProvider.getIfAvailable(RestClient::builder), responseErrorHandler); + + return CohereMultimodalEmbeddingModel.builder() + .cohereApi(cohereApi) + .options(embeddingProperties.getOptions()) + .retryTemplate(retryTemplate) + .observationRegistry(observationRegistry.getIfUnique(() -> ObservationRegistry.NOOP)) + .build(); + } + + private CohereApi cohereApi(String apiKey, String commonApiKey, String baseUrl, String commonBaseUrl, + RestClient.Builder restClientBuilder, ResponseErrorHandler responseErrorHandler) { + + var resolvedApiKey = StringUtils.hasText(apiKey) ? apiKey : commonApiKey; + var resoledBaseUrl = StringUtils.hasText(baseUrl) ? baseUrl : commonBaseUrl; + + Assert.hasText(resolvedApiKey, "Cohere API key must be set"); + Assert.hasText(resoledBaseUrl, "Cohere base URL must be set"); + + return CohereApi.builder() + .baseUrl(resoledBaseUrl) + .apiKey(resolvedApiKey) + .restClientBuilder(restClientBuilder) + .responseErrorHandler(responseErrorHandler) + .build(); + } + +} diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/java/org/springframework/ai/cohere/autoconfigure/CohereMultimodalEmbeddingProperties.java b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/java/org/springframework/ai/cohere/autoconfigure/CohereMultimodalEmbeddingProperties.java new file mode 100644 index 00000000000..8caa70cdcc8 --- /dev/null +++ b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/java/org/springframework/ai/cohere/autoconfigure/CohereMultimodalEmbeddingProperties.java @@ -0,0 +1,55 @@ +/* + * Copyright 2023-2025 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.cohere.autoconfigure; + +import java.util.List; + +import org.springframework.ai.cohere.api.CohereApi.EmbeddingModel; +import org.springframework.ai.cohere.api.CohereApi.EmbeddingType; +import org.springframework.ai.cohere.embedding.CohereMultimodalEmbeddingOptions; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.NestedConfigurationProperty; + +/** + * Configuration properties for Cohere multimodal embedding model. + * + * @author Ricken Bazolo + */ +@ConfigurationProperties(CohereMultimodalEmbeddingProperties.CONFIG_PREFIX) +public class CohereMultimodalEmbeddingProperties extends CohereParentProperties { + + public static final String CONFIG_PREFIX = "spring.ai.cohere.embedding.multimodal"; + + public static final String DEFAULT_EMBEDDING_MODEL = EmbeddingModel.EMBED_V4.getValue(); + + public static final String DEFAULT_ENCODING_FORMAT = EmbeddingType.FLOAT.name(); + + @NestedConfigurationProperty + private final CohereMultimodalEmbeddingOptions options = CohereMultimodalEmbeddingOptions.builder() + .model(DEFAULT_EMBEDDING_MODEL) + .embeddingTypes(List.of(EmbeddingType.valueOf(DEFAULT_ENCODING_FORMAT))) + .build(); + + public CohereMultimodalEmbeddingProperties() { + super.setBaseUrl(CohereCommonProperties.DEFAULT_BASE_URL); + } + + public CohereMultimodalEmbeddingOptions getOptions() { + return this.options; + } + +} diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index 8ac66e25bce..1643591155e 100644 --- a/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -15,3 +15,4 @@ # org.springframework.ai.cohere.autoconfigure.CohereChatAutoConfiguration org.springframework.ai.cohere.autoconfigure.CohereEmbeddingAutoConfiguration +org.springframework.ai.cohere.autoconfigure.CohereMultimodalEmbeddingAutoConfiguration diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/test/java/org/springframework/ai/cohere/autoconfigure/CohereAutoConfigurationIT.java b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/test/java/org/springframework/ai/cohere/autoconfigure/CohereAutoConfigurationIT.java index 8999ed59c0a..d8ec9261f35 100644 --- a/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/test/java/org/springframework/ai/cohere/autoconfigure/CohereAutoConfigurationIT.java +++ b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/test/java/org/springframework/ai/cohere/autoconfigure/CohereAutoConfigurationIT.java @@ -26,6 +26,9 @@ import org.springframework.ai.cohere.chat.CohereChatModel; import org.springframework.ai.cohere.embedding.CohereEmbeddingModel; +import org.springframework.ai.document.Document; +import org.springframework.ai.embedding.DocumentEmbeddingRequest; +import org.springframework.ai.embedding.EmbeddingOptions; import org.springframework.ai.embedding.EmbeddingResponse; import org.springframework.ai.utils.SpringAiTestAutoConfigurations; import org.springframework.boot.autoconfigure.AutoConfigurations; @@ -92,4 +95,33 @@ void generateStreaming() { }); } + @Test + public void multimodalEmbedding() { + this.contextRunner + .withConfiguration(SpringAiTestAutoConfigurations.of(CohereMultimodalEmbeddingAutoConfiguration.class)) + .run(context -> { + var multimodalEmbeddingProperties = context.getBean(CohereMultimodalEmbeddingProperties.class); + + assertThat(multimodalEmbeddingProperties).isNotNull(); + + var multiModelEmbeddingModel = context + .getBean(org.springframework.ai.cohere.embedding.CohereMultimodalEmbeddingModel.class); + + assertThat(multiModelEmbeddingModel).isNotNull(); + + var document = new Document("Hello World"); + + DocumentEmbeddingRequest embeddingRequest = new DocumentEmbeddingRequest(List.of(document), + EmbeddingOptions.builder().build()); + + EmbeddingResponse embeddingResponse = multiModelEmbeddingModel.call(embeddingRequest); + assertThat(embeddingResponse.getResults()).hasSize(1); + assertThat(embeddingResponse.getResults().get(0)).isNotNull(); + assertThat(embeddingResponse.getResults().get(0).getOutput()).hasSize(1536); + + assertThat(multiModelEmbeddingModel.dimensions()).isEqualTo(1536); + + }); + } + } diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/test/java/org/springframework/ai/cohere/autoconfigure/CohereModelConfigurationTests.java b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/test/java/org/springframework/ai/cohere/autoconfigure/CohereModelConfigurationTests.java index 0d734a8d06d..79ad7a6e21f 100644 --- a/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/test/java/org/springframework/ai/cohere/autoconfigure/CohereModelConfigurationTests.java +++ b/auto-configurations/models/spring-ai-autoconfigure-model-cohere/src/test/java/org/springframework/ai/cohere/autoconfigure/CohereModelConfigurationTests.java @@ -20,6 +20,7 @@ import org.springframework.ai.cohere.chat.CohereChatModel; import org.springframework.ai.cohere.embedding.CohereEmbeddingModel; +import org.springframework.ai.cohere.embedding.CohereMultimodalEmbeddingModel; import org.springframework.ai.utils.SpringAiTestAutoConfigurations; import org.springframework.boot.test.context.runner.ApplicationContextRunner; @@ -40,6 +41,10 @@ public class CohereModelConfigurationTests { .withPropertyValues("spring.ai.cohere.apiKey=" + System.getenv("COHERE_API_KEY")) .withConfiguration(SpringAiTestAutoConfigurations.of(CohereEmbeddingAutoConfiguration.class)); + private final ApplicationContextRunner embeddingMultimodalContextRunner = new ApplicationContextRunner() + .withPropertyValues("spring.ai.cohere.apiKey=" + System.getenv("COHERE_API_KEY")) + .withConfiguration(SpringAiTestAutoConfigurations.of(CohereMultimodalEmbeddingAutoConfiguration.class)); + @Test void chatModelActivation() { this.chatContextRunner.run(context -> { @@ -80,4 +85,22 @@ void embeddingModelActivation() { }); } + @Test + void multimodalEmbeddingActivation() { + this.embeddingMultimodalContextRunner + .run(context -> assertThat(context.getBeansOfType(CohereMultimodalEmbeddingModel.class)).isNotEmpty()); + + this.embeddingMultimodalContextRunner.withPropertyValues("spring.ai.model.embedding.multimodal=none") + .run(context -> { + assertThat(context.getBeansOfType(CohereMultimodalEmbeddingProperties.class)).isEmpty(); + assertThat(context.getBeansOfType(CohereMultimodalEmbeddingModel.class)).isEmpty(); + }); + + this.embeddingMultimodalContextRunner.withPropertyValues("spring.ai.model.embedding.multimodal=cohere") + .run(context -> { + assertThat(context.getBeansOfType(CohereMultimodalEmbeddingProperties.class)).isNotEmpty(); + assertThat(context.getBeansOfType(CohereMultimodalEmbeddingModel.class)).isNotEmpty(); + }); + } + } diff --git a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/api/CohereApi.java b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/api/CohereApi.java index 74251828d8e..c540b09a9c6 100644 --- a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/api/CohereApi.java +++ b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/api/CohereApi.java @@ -126,7 +126,7 @@ public ResponseEntity chatCompletionEntity(ChatCompletionRequest } /** - * Creates an embedding vector representing the input text or token array. + * Creates an embedding vector representing the input text, token array, or images. * @param embeddingRequest The embedding request. * @return Returns {@link EmbeddingResponse} with embeddings data. * @param Type of the entity in the data list. Can be a {@link String} or @@ -140,8 +140,19 @@ public ResponseEntity embeddings(EmbeddingRequest embe Assert.notNull(embeddingRequest, "The request body can not be null."); - Assert.isTrue(!CollectionUtils.isEmpty(embeddingRequest.texts), "The texts list can not be empty."); - Assert.isTrue(embeddingRequest.texts.size() <= 96, "The list must be 96 items or less"); + boolean hasTexts = !CollectionUtils.isEmpty(embeddingRequest.texts); + boolean hasImages = !CollectionUtils.isEmpty(embeddingRequest.images); + + Assert.isTrue(hasTexts || hasImages, "Either texts or images must be provided"); + Assert.isTrue(!(hasTexts && hasImages), "Cannot provide both texts and images in the same request"); + + if (hasTexts) { + Assert.isTrue(embeddingRequest.texts.size() <= 96, "The texts list must be 96 items or less"); + } + + if (hasImages) { + Assert.isTrue(embeddingRequest.images.size() <= 1, "Only one image per request is supported"); + } return this.restClient.post() .uri("/v2/embed") @@ -1082,9 +1093,10 @@ public enum EmbeddingType { * Embedding request. * * @param texts An array of strings to embed. + * @param images An array of images to embed as data URIs. * @param model The model to use for embedding. * @param inputType The type of input (search_document, search_query, classification, - * clustering). + * clustering, image). * @param embeddingTypes The types of embeddings to return (float, int8, uint8, * binary, ubinary). * @param truncate How to handle inputs longer than the maximum token length (NONE, @@ -1095,6 +1107,7 @@ public enum EmbeddingType { public record EmbeddingRequest( // @formatter:off @JsonProperty("texts") List texts, + @JsonProperty("images") List images, @JsonProperty("model") String model, @JsonProperty("input_type") InputType inputType, @JsonProperty("embedding_types") List embeddingTypes, @@ -1111,6 +1124,8 @@ public static final class Builder { private List texts; + private List images; + private InputType inputType = InputType.SEARCH_DOCUMENT; private List embeddingTypes = List.of(EmbeddingType.FLOAT); @@ -1137,6 +1152,11 @@ public Builder texts(Object raw) { return this; } + public Builder images(List images) { + this.images = images; + return this; + } + public Builder inputType(InputType inputType) { this.inputType = inputType; return this; @@ -1153,7 +1173,7 @@ public Builder truncate(Truncate truncate) { } public EmbeddingRequest build() { - return new EmbeddingRequest<>(this.texts, this.model, this.inputType, this.embeddingTypes, + return new EmbeddingRequest<>(this.texts, this.images, this.model, this.inputType, this.embeddingTypes, this.truncate); } diff --git a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/embedding/CohereEmbeddingUtils.java b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/embedding/CohereEmbeddingUtils.java new file mode 100644 index 00000000000..997fb4bd89e --- /dev/null +++ b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/embedding/CohereEmbeddingUtils.java @@ -0,0 +1,88 @@ +/* + * Copyright 2023-2025 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.cohere.embedding; + +import java.util.Base64; +import java.util.List; + +import org.springframework.ai.content.Media; +import org.springframework.util.Assert; +import org.springframework.util.MimeType; +import org.springframework.util.MimeTypeUtils; + +/** + * Utility class for Cohere embedding operations. + * + * @author Ricken Bazolo + */ +public final class CohereEmbeddingUtils { + + private static final List SUPPORTED_IMAGE_TYPES = List.of(MimeTypeUtils.IMAGE_JPEG, + MimeTypeUtils.IMAGE_PNG, MimeTypeUtils.parseMimeType("image/webp"), MimeTypeUtils.IMAGE_GIF); + + private static final long MAX_IMAGE_SIZE_BYTES = 5 * 1024 * 1024; + + private CohereEmbeddingUtils() { + } + + public static String mediaToDataUri(Media media) { + Assert.notNull(media, "Media cannot be null"); + validateImageMedia(media); + + byte[] imageData = getImageBytes(media); + validateImageSize(imageData); + + String base64Data = Base64.getEncoder().encodeToString(imageData); + String mimeType = media.getMimeType().toString(); + + return String.format("data:%s;base64,%s", mimeType, base64Data); + } + + private static void validateImageMedia(Media media) { + MimeType mimeType = media.getMimeType(); + boolean isSupported = SUPPORTED_IMAGE_TYPES.stream() + .anyMatch(supported -> mimeType.isCompatibleWith(supported)); + + if (!isSupported) { + throw new IllegalArgumentException("Unsupported image MIME type: " + mimeType + + ". Supported types: image/jpeg, image/png, image/webp, image/gif"); + } + } + + private static byte[] getImageBytes(Media media) { + Object data = media.getData(); + + if (data instanceof byte[] bytes) { + return bytes; + } + else if (data instanceof String base64String) { + return Base64.getDecoder().decode(base64String); + } + else { + throw new IllegalArgumentException("Media data must be byte[] or base64 String"); + } + } + + private static void validateImageSize(byte[] imageData) { + if (imageData.length > MAX_IMAGE_SIZE_BYTES) { + throw new IllegalArgumentException( + String.format("Image size (%d bytes) exceeds maximum allowed size (%d bytes)", imageData.length, + MAX_IMAGE_SIZE_BYTES)); + } + } + +} diff --git a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/embedding/CohereMultimodalEmbeddingModel.java b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/embedding/CohereMultimodalEmbeddingModel.java new file mode 100644 index 00000000000..15ea8b5b17e --- /dev/null +++ b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/embedding/CohereMultimodalEmbeddingModel.java @@ -0,0 +1,208 @@ +/* + * Copyright 2023-2025 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.cohere.embedding; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import io.micrometer.observation.ObservationRegistry; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.ai.cohere.api.CohereApi; +import org.springframework.ai.document.Document; +import org.springframework.ai.embedding.DocumentEmbeddingModel; +import org.springframework.ai.embedding.DocumentEmbeddingRequest; +import org.springframework.ai.embedding.Embedding; +import org.springframework.ai.embedding.EmbeddingResponse; +import org.springframework.ai.embedding.EmbeddingResponseMetadata; +import org.springframework.ai.model.ModelOptionsUtils; +import org.springframework.ai.retry.RetryUtils; +import org.springframework.core.retry.RetryTemplate; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * Implementation of the Cohere Multimodal Embedding Model. + * + * @author Ricken Bazolo + */ +public class CohereMultimodalEmbeddingModel implements DocumentEmbeddingModel { + + private static final Logger logger = LoggerFactory.getLogger(CohereMultimodalEmbeddingModel.class); + + private static final Map KNOWN_EMBEDDING_DIMENSIONS = Map.of( + CohereApi.EmbeddingModel.EMBED_MULTILINGUAL_V3.getValue(), 1024, + CohereApi.EmbeddingModel.EMBED_ENGLISH_V3.getValue(), 1024, + CohereApi.EmbeddingModel.EMBED_MULTILINGUAL_LIGHT_V3.getValue(), 384, + CohereApi.EmbeddingModel.EMBED_ENGLISH_LIGHT_V3.getValue(), 384, + CohereApi.EmbeddingModel.EMBED_V4.getValue(), 1536); + + private final CohereMultimodalEmbeddingOptions defaultOptions; + + private final CohereApi cohereApi; + + private final RetryTemplate retryTemplate; + + private final ObservationRegistry observationRegistry; + + public CohereMultimodalEmbeddingModel(CohereApi cohereApi, CohereMultimodalEmbeddingOptions options, + RetryTemplate retryTemplate, ObservationRegistry observationRegistry) { + Assert.notNull(cohereApi, "cohereApi must not be null"); + Assert.notNull(options, "options must not be null"); + Assert.notNull(retryTemplate, "retryTemplate must not be null"); + Assert.notNull(observationRegistry, "observationRegistry must not be null"); + + this.cohereApi = cohereApi; + this.defaultOptions = options; + this.retryTemplate = retryTemplate; + this.observationRegistry = observationRegistry; + } + + @Override + public EmbeddingResponse call(DocumentEmbeddingRequest request) { + CohereMultimodalEmbeddingOptions mergedOptions = mergeOptions(request.getOptions(), this.defaultOptions); + + List allEmbeddings = new ArrayList<>(); + EmbeddingResponseMetadata lastMetadata = null; + + for (Document document : request.getInstructions()) { + CohereApi.EmbeddingRequest apiRequest; + + if (document.getMedia() != null) { + apiRequest = createImageRequest(document, mergedOptions); + } + else if (StringUtils.hasText(document.getText())) { + apiRequest = createTextRequest(document, mergedOptions); + } + else { + logger.warn("Document {} has no text or media content", document.getId()); + continue; + } + + var apiResponse = RetryUtils.execute(this.retryTemplate, + () -> this.cohereApi.embeddings(apiRequest).getBody()); + + if (apiResponse != null) { + List floatEmbeddings = apiResponse.getFloatEmbeddings(); + for (int i = 0; i < floatEmbeddings.size(); i++) { + allEmbeddings.add(new Embedding(floatEmbeddings.get(i), allEmbeddings.size())); + } + lastMetadata = generateResponseMetadata(apiResponse.responseType()); + } + } + + return new EmbeddingResponse(allEmbeddings, lastMetadata); + } + + @Override + public int dimensions() { + String model = this.defaultOptions.getModel(); + if (model == null) { + return KNOWN_EMBEDDING_DIMENSIONS.get(CohereApi.EmbeddingModel.EMBED_V4.getValue()); + } + return KNOWN_EMBEDDING_DIMENSIONS.getOrDefault(model, 1024); + } + + private CohereApi.EmbeddingRequest createTextRequest(Document document, + CohereMultimodalEmbeddingOptions options) { + return CohereApi.EmbeddingRequest.builder() + .model(options.getModel()) + .inputType(CohereApi.EmbeddingRequest.InputType.CLASSIFICATION) + .embeddingTypes(options.getEmbeddingTypes()) + .texts(List.of(document.getText())) + .truncate(options.getTruncate()) + .build(); + } + + private CohereApi.EmbeddingRequest createImageRequest(Document document, + CohereMultimodalEmbeddingOptions options) { + + String dataUri = CohereEmbeddingUtils.mediaToDataUri(document.getMedia()); + + return CohereApi.EmbeddingRequest.builder() + .model(options.getModel()) + .inputType(CohereApi.EmbeddingRequest.InputType.IMAGE) + .embeddingTypes(options.getEmbeddingTypes()) + .images(List.of(dataUri)) + .truncate(options.getTruncate()) + .build(); + } + + private CohereMultimodalEmbeddingOptions mergeOptions( + org.springframework.ai.embedding.EmbeddingOptions requestOptions, + CohereMultimodalEmbeddingOptions defaultOptions) { + CohereMultimodalEmbeddingOptions options = (requestOptions != null) + ? ModelOptionsUtils.merge(requestOptions, defaultOptions, CohereMultimodalEmbeddingOptions.class) + : defaultOptions; + + if (options == null) { + throw new IllegalArgumentException("Embedding options must not be null"); + } + + return options; + } + + private EmbeddingResponseMetadata generateResponseMetadata(String embeddingType) { + return new EmbeddingResponseMetadata(embeddingType, null); + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + + private CohereApi cohereApi; + + private CohereMultimodalEmbeddingOptions options = CohereMultimodalEmbeddingOptions.builder() + .model(CohereApi.EmbeddingModel.EMBED_V4.getValue()) + .build(); + + private RetryTemplate retryTemplate = RetryUtils.DEFAULT_RETRY_TEMPLATE; + + private ObservationRegistry observationRegistry = ObservationRegistry.NOOP; + + public Builder cohereApi(CohereApi cohereApi) { + this.cohereApi = cohereApi; + return this; + } + + public Builder options(CohereMultimodalEmbeddingOptions options) { + this.options = options; + return this; + } + + public Builder retryTemplate(RetryTemplate retryTemplate) { + this.retryTemplate = retryTemplate; + return this; + } + + public Builder observationRegistry(ObservationRegistry observationRegistry) { + this.observationRegistry = observationRegistry; + return this; + } + + public CohereMultimodalEmbeddingModel build() { + return new CohereMultimodalEmbeddingModel(this.cohereApi, this.options, this.retryTemplate, + this.observationRegistry); + } + + } + +} diff --git a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/embedding/CohereMultimodalEmbeddingOptions.java b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/embedding/CohereMultimodalEmbeddingOptions.java new file mode 100644 index 00000000000..40ad1d3ed41 --- /dev/null +++ b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/embedding/CohereMultimodalEmbeddingOptions.java @@ -0,0 +1,177 @@ +/* + * Copyright 2023-2025 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.cohere.embedding; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +import org.springframework.ai.cohere.api.CohereApi; +import org.springframework.ai.cohere.api.CohereApi.EmbeddingRequest.InputType; +import org.springframework.ai.cohere.api.CohereApi.EmbeddingRequest.Truncate; +import org.springframework.ai.cohere.api.CohereApi.EmbeddingType; +import org.springframework.ai.embedding.EmbeddingOptions; + +/** + * Options for the Cohere Multimodal Embedding API. + * + * @author Ricken Bazolo + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public final class CohereMultimodalEmbeddingOptions implements EmbeddingOptions { + + @JsonProperty("model") + private String model; + + @JsonProperty("input_type") + private InputType inputType; + + @JsonProperty("embedding_types") + private List embeddingTypes = new ArrayList<>(); + + @JsonProperty("truncate") + private Truncate truncate; + + public static Builder builder() { + return new Builder(); + } + + public static CohereMultimodalEmbeddingOptions fromOptions(CohereMultimodalEmbeddingOptions fromOptions) { + return builder().model(fromOptions.getModel()) + .inputType(fromOptions.getInputType()) + .embeddingTypes( + fromOptions.getEmbeddingTypes() != null ? new ArrayList<>(fromOptions.getEmbeddingTypes()) : null) + .truncate(fromOptions.getTruncate()) + .build(); + } + + private CohereMultimodalEmbeddingOptions() { + this.embeddingTypes.add(EmbeddingType.FLOAT); + this.inputType = InputType.CLASSIFICATION; + this.model = CohereApi.EmbeddingModel.EMBED_V4.getValue(); + } + + @Override + public String getModel() { + return this.model; + } + + public void setModel(String model) { + this.model = model; + } + + public InputType getInputType() { + return this.inputType; + } + + public void setInputType(InputType inputType) { + this.inputType = inputType; + } + + public List getEmbeddingTypes() { + return this.embeddingTypes; + } + + public void setEmbeddingTypes(List embeddingTypes) { + this.embeddingTypes = embeddingTypes; + } + + public Truncate getTruncate() { + return this.truncate; + } + + public void setTruncate(Truncate truncate) { + this.truncate = truncate; + } + + @Override + public Integer getDimensions() { + return null; + } + + public CohereMultimodalEmbeddingOptions copy() { + return fromOptions(this); + } + + @Override + public int hashCode() { + return Objects.hash(this.model, this.inputType, this.embeddingTypes, this.truncate); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + + CohereMultimodalEmbeddingOptions that = (CohereMultimodalEmbeddingOptions) o; + + return Objects.equals(this.model, that.model) && Objects.equals(this.inputType, that.inputType) + && Objects.equals(this.embeddingTypes, that.embeddingTypes) + && Objects.equals(this.truncate, that.truncate); + } + + public static final class Builder { + + private CohereMultimodalEmbeddingOptions options; + + public Builder() { + this.options = new CohereMultimodalEmbeddingOptions(); + } + + public Builder(CohereMultimodalEmbeddingOptions options) { + this.options = options; + } + + public Builder model(String model) { + this.options.model = model; + return this; + } + + public Builder model(CohereApi.EmbeddingModel model) { + this.options.model = model.getValue(); + return this; + } + + public Builder inputType(InputType inputType) { + this.options.inputType = inputType; + return this; + } + + public Builder embeddingTypes(List embeddingTypes) { + this.options.embeddingTypes = embeddingTypes; + return this; + } + + public Builder truncate(Truncate truncate) { + this.options.truncate = truncate; + return this; + } + + public CohereMultimodalEmbeddingOptions build() { + return this.options; + } + + } + +} diff --git a/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/CohereTestConfiguration.java b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/CohereTestConfiguration.java index 3f2173f7d93..4c03a23efb9 100644 --- a/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/CohereTestConfiguration.java +++ b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/CohereTestConfiguration.java @@ -20,6 +20,7 @@ import org.springframework.ai.cohere.chat.CohereChatModel; import org.springframework.ai.cohere.chat.CohereChatOptions; import org.springframework.ai.cohere.embedding.CohereEmbeddingModel; +import org.springframework.ai.cohere.embedding.CohereMultimodalEmbeddingModel; import org.springframework.boot.SpringBootConfiguration; import org.springframework.context.annotation.Bean; import org.springframework.util.StringUtils; @@ -57,4 +58,9 @@ public CohereEmbeddingModel cohereEmbeddingModel(CohereApi api) { return CohereEmbeddingModel.builder().cohereApi(api).build(); } + @Bean + public CohereMultimodalEmbeddingModel cohereMultimodalEmbeddingModel(CohereApi api) { + return CohereMultimodalEmbeddingModel.builder().cohereApi(api).build(); + } + } diff --git a/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/embedding/CohereMultimodalEmbeddingIT.java b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/embedding/CohereMultimodalEmbeddingIT.java new file mode 100644 index 00000000000..679e9f85874 --- /dev/null +++ b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/embedding/CohereMultimodalEmbeddingIT.java @@ -0,0 +1,107 @@ +/* + * Copyright 2023-2025 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.cohere.embedding; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; + +import org.springframework.ai.cohere.CohereTestConfiguration; +import org.springframework.ai.cohere.api.CohereApi; +import org.springframework.ai.content.Media; +import org.springframework.ai.document.Document; +import org.springframework.ai.embedding.DocumentEmbeddingRequest; +import org.springframework.ai.embedding.EmbeddingResponse; +import org.springframework.ai.embedding.EmbeddingResultMetadata; +import org.springframework.ai.embedding.EmbeddingResultMetadata.ModalityType; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.core.io.ClassPathResource; +import org.springframework.util.MimeTypeUtils; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Ricken Bazolo + */ +@SpringBootTest(classes = CohereTestConfiguration.class) +@EnabledIfEnvironmentVariable(named = "COHERE_API_KEY", matches = ".+") +class CohereMultimodalEmbeddingIT { + + private static final int EMBED_DIMENSIONS = 1536; + + @Autowired + private CohereApi cohereApi; + + @Autowired + private CohereMultimodalEmbeddingModel cohereMultimodalEmbeddingModel; + + @Test + void imageEmbedding() { + + var document = Document.builder() + .media(new Media(MimeTypeUtils.IMAGE_PNG, new ClassPathResource("/test.image.png"))) + .build(); + + DocumentEmbeddingRequest embeddingRequest = new DocumentEmbeddingRequest(document); + + EmbeddingResponse embeddingResponse = this.cohereMultimodalEmbeddingModel.call(embeddingRequest); + assertThat(embeddingResponse.getResults()).hasSize(1); + + assertThat(embeddingResponse.getResults().get(0)).isNotNull(); + assertThat(embeddingResponse.getResults().get(0).getMetadata().getModalityType()).isEqualTo(ModalityType.TEXT); + assertThat(embeddingResponse.getResults().get(0).getMetadata().getMimeType()) + .isEqualTo(MimeTypeUtils.TEXT_PLAIN); + + assertThat(embeddingResponse.getResults().get(0).getOutput()).hasSize(EMBED_DIMENSIONS); + + assertThat(embeddingResponse.getMetadata().getModel()).isEqualTo("embeddings_by_type"); + assertThat(embeddingResponse.getMetadata().getUsage().getTotalTokens()).isEqualTo(0); + + assertThat(this.cohereMultimodalEmbeddingModel.dimensions()).isEqualTo(EMBED_DIMENSIONS); + } + + @Test + void textAndImageEmbedding() { + + var textDocument = Document.builder().text("Hello World").build(); + + var imageDocument = Document.builder() + .media(new Media(MimeTypeUtils.IMAGE_PNG, new ClassPathResource("/test.image.png"))) + .build(); + + DocumentEmbeddingRequest embeddingRequest = new DocumentEmbeddingRequest(List.of(textDocument, imageDocument)); + + EmbeddingResponse embeddingResponse = this.cohereMultimodalEmbeddingModel.call(embeddingRequest); + assertThat(embeddingResponse.getResults()).hasSize(2); + assertThat(embeddingResponse.getResults().get(0)).isNotNull(); + assertThat(embeddingResponse.getResults().get(0).getMetadata().getModalityType()) + .isEqualTo(EmbeddingResultMetadata.ModalityType.TEXT); + assertThat(embeddingResponse.getResults().get(0).getOutput()).hasSize(EMBED_DIMENSIONS); + + assertThat(embeddingResponse.getResults().get(1)).isNotNull(); + assertThat(embeddingResponse.getResults().get(1).getMetadata().getModalityType()).isEqualTo(ModalityType.TEXT); + assertThat(embeddingResponse.getResults().get(1).getOutput()).hasSize(EMBED_DIMENSIONS); + + assertThat(embeddingResponse.getMetadata().getModel()).isEqualTo("embeddings_by_type"); + assertThat(embeddingResponse.getMetadata().getUsage().getTotalTokens()).isEqualTo(0); + + assertThat(this.cohereMultimodalEmbeddingModel.dimensions()).isEqualTo(EMBED_DIMENSIONS); + } + +} diff --git a/models/spring-ai-cohere/src/test/resources/test.image.png b/models/spring-ai-cohere/src/test/resources/test.image.png new file mode 100644 index 0000000000000000000000000000000000000000..8abb4c81aea34f9238ce5abeabfbcc8db7ff0cb3 GIT binary patch literal 167772 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

48FLmZ)xyub>{lu#+uX7FE)!_XE4D0pBKKwA>R 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 literal 0 HcmV?d00001 From dd82e469856da752c14eea8b58cd2e0a9567816f Mon Sep 17 00:00:00 2001 From: ricken07 Date: Thu, 27 Nov 2025 18:21:44 +0100 Subject: [PATCH 17/18] added cohere support :: text and multimodal documentation Signed-off-by: ricken07 --- .../schema/CohereToolCallingManager.java | 94 ----- .../src/main/antora/modules/ROOT/nav.adoc | 4 +- .../cohere-embeddings-multimodal.adoc | 351 ++++++++++++++++++ ...dings.adoc => cohere-embeddings-text.adoc} | 13 +- 4 files changed, 362 insertions(+), 100 deletions(-) delete mode 100644 models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/schema/CohereToolCallingManager.java create mode 100644 spring-ai-docs/src/main/antora/modules/ROOT/pages/api/embeddings/cohere-embeddings-multimodal.adoc rename spring-ai-docs/src/main/antora/modules/ROOT/pages/api/embeddings/{cohere-embeddings.adoc => cohere-embeddings-text.adoc} (96%) diff --git a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/schema/CohereToolCallingManager.java b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/schema/CohereToolCallingManager.java deleted file mode 100644 index 879ab21b86b..00000000000 --- a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/schema/CohereToolCallingManager.java +++ /dev/null @@ -1,94 +0,0 @@ -/* - * Copyright 2025-2025 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.cohere.schema; - -import java.util.List; - -import org.springframework.ai.chat.model.ChatResponse; -import org.springframework.ai.chat.prompt.Prompt; -import org.springframework.ai.model.tool.ToolCallingChatOptions; -import org.springframework.ai.model.tool.ToolCallingManager; -import org.springframework.ai.model.tool.ToolExecutionResult; -import org.springframework.ai.tool.definition.ToolDefinition; -import org.springframework.util.Assert; - -/** - * Implementation of {@link ToolCallingManager} specifically designed for Vertex AI - * Gemini. This manager adapts tool definitions to be compatible with Vertex AI's OpenAPI - * schema format by converting JSON schemas and ensuring proper type value upper-casing. - * - *

- * It delegates the actual tool execution to another {@link ToolCallingManager} while - * handling the necessary schema conversions for Vertex AI compatibility. - * - * @author Christian Tzolov - * @since 1.0.0 - */ -public class CohereToolCallingManager implements ToolCallingManager { - - /** - * The underlying tool calling manager that handles actual tool execution. - */ - private final ToolCallingManager delegateToolCallingManager; - - /** - * Creates a new instance of VertexToolCallingManager. - * @param delegateToolCallingManager the underlying tool calling manager that handles - * actual tool execution - */ - public CohereToolCallingManager(ToolCallingManager delegateToolCallingManager) { - Assert.notNull(delegateToolCallingManager, "Delegate tool calling manager must not be null"); - this.delegateToolCallingManager = delegateToolCallingManager; - } - - /** - * Resolves tool definitions and converts their input schemas to be compatible with - * Vertex AI's OpenAPI format. This includes converting JSON schemas to OpenAPI format - * and ensuring proper type value casing. - * @param chatOptions the options containing tool preferences and configurations - * @return a list of tool definitions with Vertex AI compatible schemas - */ - @Override - public List resolveToolDefinitions(ToolCallingChatOptions chatOptions) { - - List toolDefinitions = this.delegateToolCallingManager.resolveToolDefinitions(chatOptions); - - /* - * return toolDefinitions.stream().map(td -> { ObjectNode jsonSchema = - * JsonSchemaConverter.fromJson(td.inputSchema()); ObjectNode openApiSchema = - * JsonSchemaConverter.convertToOpenApiSchema(jsonSchema); - * JsonSchemaGenerator.convertTypeValuesToUpperCase(openApiSchema); - * - * return DefaultToolDefinition.builder() .name(td.name()) - * .description(td.description()) .inputSchema(openApiSchema.toPrettyString()) - * .build(); }).toList(); - */ - return List.of(); - } - - /** - * Executes tool calls by delegating to the underlying tool calling manager. - * @param prompt the original prompt that triggered the tool calls - * @param chatResponse the chat response containing the tool calls to execute - * @return the result of executing the tool calls - */ - @Override - public ToolExecutionResult executeToolCalls(Prompt prompt, ChatResponse chatResponse) { - return this.delegateToolCallingManager.executeToolCalls(prompt, chatResponse); - } - -} diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/nav.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/nav.adoc index d485152ee97..fd289524caa 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/nav.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/nav.adoc @@ -57,7 +57,9 @@ ***** xref:api/embeddings/vertexai-embeddings-text.adoc[Text Embedding] ***** xref:api/embeddings/vertexai-embeddings-multimodal.adoc[Multimodal Embedding] **** xref:api/embeddings/zhipuai-embeddings.adoc[ZhiPu AI] -**** xref:api/embeddings/cohere-embeddings.adoc[Cohere] +**** Cohere +***** xref:api/embeddings/cohere-embeddings-text.adoc[Text Embedding] +***** xref:api/embeddings/cohere-embeddings-multimodal.adoc[Multimodal Embedding] *** xref:api/imageclient.adoc[Image Models] **** xref:api/image/azure-openai-image.adoc[Azure OpenAI] diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/embeddings/cohere-embeddings-multimodal.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/embeddings/cohere-embeddings-multimodal.adoc new file mode 100644 index 00000000000..464ac8b696b --- /dev/null +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/embeddings/cohere-embeddings-multimodal.adoc @@ -0,0 +1,351 @@ += Cohere Multimodal Embeddings + +Cohere supports two types of embeddings models, text and multimodal. +This document describes how to create multimodal embeddings using the Cohere link:https://docs.cohere.com/docs/embeddings[Multimodal embeddings API]. + +The multimodal embeddings model generates 1536-dimension vectors based on the input you provide, which can include a combination of image and text data. +The embedding vectors can then be used for subsequent tasks like image classification or visual search. + +The image embedding vector and text embedding vector are in the same semantic space with the same dimensionality. +Consequently, these vectors can be used interchangeably for use cases like searching images by text, or searching text by image. + +NOTE: The Cohere Multimodal API imposes the following limits: maximum 1 image per request, maximum 5MB per image, supported formats are JPEG, PNG, WebP, and GIF. + +TIP: For text-only embedding use cases, we recommend using the xref:api/embeddings/cohere-embeddings-text.adoc[Cohere text-embeddings model] instead. + +== Prerequisites + +You will need to create an API key with Cohere to access Cohere embedding models. + +Create an account at https://dashboard.cohere.com/welcome/register[Cohere registration page] and generate the token on the https://dashboard.cohere.com/api-keys[API Keys page]. + +The Spring AI project defines a configuration property named `spring.ai.cohere.api-key` that you should set to the value of the API Key obtained from dashboard.cohere.com. + +You can set this configuration property in your `application.properties` file: + +[source,properties] +---- +spring.ai.cohere.api-key= +---- + +For enhanced security when handling sensitive information like API keys, you can use Spring Expression Language (SpEL) to reference an environment variable: + +[source,yaml] +---- +# In application.yml +spring: + ai: + cohere: + api-key: ${COHERE_API_KEY} +---- + +[source,bash] +---- +# In your environment or .env file +export COHERE_API_KEY= +---- + +You can also set this configuration programmatically in your application code: + +[source,java] +---- +// Retrieve API key from a secure source or environment variable +String apiKey = System.getenv("COHERE_API_KEY"); +---- + +=== Add Repositories and BOM + +Spring AI artifacts are published in Maven Central and Spring Snapshot repositories. +Refer to the xref:getting-started.adoc#artifact-repositories[Artifact Repositories] section to add these repositories to your build system. + +To help with dependency management, Spring AI provides a BOM (bill of materials) to ensure that a consistent version of Spring AI is used throughout the entire project. Refer to the xref:getting-started.adoc#dependency-management[Dependency Management] section to add the Spring AI BOM to your build system. + + +== Auto-configuration + +[NOTE] +==== +There has been a significant change in the Spring AI auto-configuration, starter modules' artifact names. +Please refer to the https://docs.spring.io/spring-ai/reference/upgrade-notes.html[upgrade notes] for more information. +==== + +Spring AI provides Spring Boot auto-configuration for the Cohere Multimodal Embedding Model. +To enable it add the following dependency to your project's Maven `pom.xml` file: + +[source, xml] +---- + + org.springframework.ai + spring-ai-starter-model-cohere + +---- + +or to your Gradle `build.gradle` build file. + +[source,groovy] +---- +dependencies { + implementation 'org.springframework.ai:spring-ai-starter-model-cohere' +} +---- + +TIP: Refer to the xref:getting-started.adoc#dependency-management[Dependency Management] section to add the Spring AI BOM to your build file. + +=== Embedding Properties + +==== Retry Properties + +The prefix `spring.ai.retry` is used as the property prefix that lets you configure the retry mechanism for the Cohere Multimodal Embedding model. + +[cols="3,5,1", stripes=even] +|==== +| Property | Description | Default + +| spring.ai.retry.max-attempts | Maximum number of retry attempts. | 10 +| spring.ai.retry.backoff.initial-interval | Initial sleep duration for the exponential backoff policy. | 2 sec. +| spring.ai.retry.backoff.multiplier | Backoff interval multiplier. | 5 +| spring.ai.retry.backoff.max-interval | Maximum backoff duration. | 3 min. +| spring.ai.retry.on-client-errors | If false, throw a NonTransientAiException, and do not attempt retry for `4xx` client error codes | false +| spring.ai.retry.exclude-on-http-codes | List of HTTP status codes that should not trigger a retry (e.g. to throw NonTransientAiException). | empty +| spring.ai.retry.on-http-codes | List of HTTP status codes that should trigger a retry (e.g. to throw TransientAiException). | empty +|==== + +==== Connection Properties + +The prefix `spring.ai.cohere` is used as the property prefix that lets you connect to Cohere. + +[cols="3,5,1", stripes=even] +|==== +| Property | Description | Default + +| spring.ai.cohere.base-url | The URL to connect to | https://api.cohere.com +| spring.ai.cohere.api-key | The API Key | - +|==== + +==== Configuration Properties + +[NOTE] +==== +Enabling and disabling of the multimodal embedding auto-configurations are now configured via top level properties with the prefix `spring.ai.model.embedding.multimodal`. + +To enable, spring.ai.model.embedding.multimodal=cohere (It is enabled by default) + +To disable, spring.ai.model.embedding.multimodal=none (or any value which doesn't match cohere) + +This change is done to allow configuration of multiple models. +==== + +The prefix `spring.ai.cohere.embedding.multimodal` is the property prefix that configures the multimodal `EmbeddingModel` implementation for Cohere. + +[cols="3,5,1", stripes=even] +|==== +| Property | Description | Default + +| spring.ai.model.embedding.multimodal | Enable Cohere multimodal embedding model. | cohere +| spring.ai.cohere.embedding.multimodal.base-url | Optional overrides the spring.ai.cohere.base-url to provide embedding specific url | - +| spring.ai.cohere.embedding.multimodal.api-key | Optional overrides the spring.ai.cohere.api-key to provide embedding specific api-key | - +| spring.ai.cohere.embedding.multimodal.options.model | The model to use | embed-v4 +| spring.ai.cohere.embedding.multimodal.options.input-type | The type of input (search_document, search_query, classification, clustering) | classification +| spring.ai.cohere.embedding.multimodal.options.embedding-types | The types of embeddings to return (float, int8, uint8, binary, ubinary) | [float] +| spring.ai.cohere.embedding.multimodal.options.truncate | How to handle inputs longer than maximum token length (NONE, START, END) | - +|==== + +NOTE: You can override the common `spring.ai.cohere.base-url` and `spring.ai.cohere.api-key` for the `ChatModel` and `EmbeddingModel` implementations. +The `spring.ai.cohere.embedding.multimodal.base-url` and `spring.ai.cohere.embedding.multimodal.api-key` properties if set take precedence over the common properties. +Similarly, the `spring.ai.cohere.chat.base-url` and `spring.ai.cohere.chat.api-key` properties if set take precedence over the common properties. +This is useful if you want to use different Cohere accounts for different models and different model endpoints. + +TIP: All properties prefixed with `spring.ai.cohere.embedding.multimodal.options` can be overridden at runtime by adding a request specific <> to the `DocumentEmbeddingRequest` call. + +== Runtime Options [[embedding-options]] + +The https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/embedding/CohereMultimodalEmbeddingOptions.java[CohereMultimodalEmbeddingOptions.java] provides the Cohere multimodal configurations, such as the model to use and etc. + +The default options can be configured using the `spring.ai.cohere.embedding.multimodal.options` properties as well. + +At start-time use the `CohereMultimodalEmbeddingModel` constructor to set the default options used for all embedding requests. +At run-time you can override the default options, using a `CohereMultimodalEmbeddingOptions` instance as part of your `DocumentEmbeddingRequest`. + +For example to override the default model name and input type for a specific request: + +[source,java] +---- +// Create a document with text only +Document textDocument = new Document("Hello World"); + +// Create a document with an image +Media imageMedia = new Media(MimeTypeUtils.IMAGE_PNG, new ClassPathResource("/test.image.png")); +Document imageDocument = new Document("", List.of(imageMedia), Map.of()); + +// Create a document with both text and image +Document multimodalDocument = new Document("Describe this image", List.of(imageMedia), Map.of()); + +// Create embedding request with custom options +DocumentEmbeddingRequest embeddingRequest = new DocumentEmbeddingRequest( + List.of(textDocument, imageDocument, multimodalDocument), + CohereMultimodalEmbeddingOptions.builder() + .model("embed-v4") + .inputType(InputType.CLASSIFICATION) + .build()); + +EmbeddingResponse embeddingResponse = embeddingModel.call(embeddingRequest); +---- + +== Understanding Input Types + +Cohere embeddings support different input types to optimize the embeddings for specific use cases: + +* `SEARCH_DOCUMENT`: Use when embedding documents to be retrieved in a search system +* `SEARCH_QUERY`: Use when embedding search queries to match against documents +* `CLASSIFICATION`: Use for classification tasks (text or image classification) +* `CLUSTERING`: Use for clustering documents or images by similarity + +For best results in semantic search applications, use `SEARCH_DOCUMENT` for your corpus (both text and images) and `SEARCH_QUERY` for user queries. + +== Image Format Requirements + +When working with images in Cohere multimodal embeddings, note the following requirements: + +* Maximum 1 image per request +* Maximum 5MB per image +* Supported formats: JPEG, PNG, WebP, GIF +* Images are converted to Data URI format (base64-encoded) before sending to the API + +The Spring AI Cohere integration handles the conversion automatically when you provide images through `Media` objects. + +== Sample Controller + +This will create a `DocumentEmbeddingModel` implementation that you can inject into your class. +Here is an example of a simple `@Controller` class that uses the multimodal embedding implementation. + +[source,application.properties] +---- +spring.ai.cohere.api-key=YOUR_API_KEY +spring.ai.model.embedding.multimodal=cohere +spring.ai.cohere.embedding.multimodal.options.model=embed-v4 +spring.ai.cohere.embedding.multimodal.options.input-type=classification +---- + +[source,java] +---- +@RestController +public class MultimodalEmbeddingController { + + private final DocumentEmbeddingModel embeddingModel; + + @Autowired + public MultimodalEmbeddingController(DocumentEmbeddingModel embeddingModel) { + this.embeddingModel = embeddingModel; + } + + @GetMapping("/ai/embedding/text") + public Map embedText(@RequestParam(value = "message", defaultValue = "Hello World") String message) { + Document document = new Document(message); + DocumentEmbeddingRequest request = new DocumentEmbeddingRequest( + List.of(document), + EmbeddingOptions.EMPTY); + + EmbeddingResponse embeddingResponse = this.embeddingModel.call(request); + return Map.of("embedding", embeddingResponse); + } + + @PostMapping("/ai/embedding/image") + public Map embedImage(@RequestParam("file") MultipartFile file) throws IOException { + Media imageMedia = new Media( + MimeTypeUtils.parseMimeType(file.getContentType()), + file.getResource()); + + Document document = new Document("", List.of(imageMedia), Map.of()); + DocumentEmbeddingRequest request = new DocumentEmbeddingRequest( + List.of(document), + EmbeddingOptions.EMPTY); + + EmbeddingResponse embeddingResponse = this.embeddingModel.call(request); + return Map.of("embedding", embeddingResponse); + } + + @PostMapping("/ai/embedding/multimodal") + public Map embedMultimodal( + @RequestParam(value = "message", defaultValue = "Describe this image") String message, + @RequestParam("file") MultipartFile file) throws IOException { + + Media imageMedia = new Media( + MimeTypeUtils.parseMimeType(file.getContentType()), + file.getResource()); + + Document document = new Document(message, List.of(imageMedia), Map.of()); + DocumentEmbeddingRequest request = new DocumentEmbeddingRequest( + List.of(document), + EmbeddingOptions.EMPTY); + + EmbeddingResponse embeddingResponse = this.embeddingModel.call(request); + return Map.of("embedding", embeddingResponse); + } +} +---- + +== Manual Configuration + +If you are not using Spring Boot, you can manually configure the Cohere Multimodal Embedding Model. +For this add the `spring-ai-cohere` dependency to your project's Maven `pom.xml` file: + +[source, xml] +---- + + org.springframework.ai + spring-ai-cohere + +---- + +or to your Gradle `build.gradle` build file. + +[source,groovy] +---- +dependencies { + implementation 'org.springframework.ai:spring-ai-cohere' +} +---- + +TIP: Refer to the xref:getting-started.adoc#dependency-management[Dependency Management] section to add the Spring AI BOM to your build file. + +NOTE: The `spring-ai-cohere` dependency provides access also to the `CohereChatModel`. +For more information about the `CohereChatModel` refer to the link:../chat/cohere-chat.html[Cohere Chat Client] section. + +Next, create a `CohereMultimodalEmbeddingModel` instance and use it to compute embeddings for text and images: + +[source,java] +---- +var cohereApi = new CohereApi(System.getenv("COHERE_API_KEY")); + +var embeddingModel = CohereMultimodalEmbeddingModel.builder() + .cohereApi(cohereApi) + .options(CohereMultimodalEmbeddingOptions.builder() + .model("embed-v4") + .inputType(InputType.CLASSIFICATION) + .embeddingTypes(List.of(EmbeddingType.FLOAT)) + .build()) + .build(); + +// Embedding text +Document textDocument = new Document("Hello World"); + +// Embedding an image +Media imageMedia = new Media(MimeTypeUtils.IMAGE_PNG, new ClassPathResource("/test.image.png")); +Document imageDocument = new Document("", List.of(imageMedia), Map.of()); + +// Embedding text with image +Document multimodalDocument = new Document("Describe this image", List.of(imageMedia), Map.of()); + +DocumentEmbeddingRequest embeddingRequest = new DocumentEmbeddingRequest( + List.of(textDocument, imageDocument, multimodalDocument), + EmbeddingOptions.EMPTY); + +EmbeddingResponse embeddingResponse = embeddingModel.call(embeddingRequest); + +// Each document gets its own embedding result +assertThat(embeddingResponse.getResults()).hasSize(3); +assertThat(embeddingResponse.getResults().get(0).getOutput()).hasSize(1536); +---- + +The `CohereMultimodalEmbeddingOptions` provides the configuration information for the embedding requests. +The options class offers a `builder()` for easy options creation. diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/embeddings/cohere-embeddings.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/embeddings/cohere-embeddings-text.adoc similarity index 96% rename from spring-ai-docs/src/main/antora/modules/ROOT/pages/api/embeddings/cohere-embeddings.adoc rename to spring-ai-docs/src/main/antora/modules/ROOT/pages/api/embeddings/cohere-embeddings-text.adoc index 37c50f86b75..2a7b1cf72ec 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/embeddings/cohere-embeddings.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/embeddings/cohere-embeddings-text.adoc @@ -1,6 +1,8 @@ -= Cohere Embeddings += Cohere Text Embeddings + +Cohere supports two types of embeddings models, text and multimodal. +This document describes how to create text embeddings using the Cohere link:https://docs.cohere.com/docs/embeddings[Text embeddings API]. -Spring AI supports Cohere's text embedding models. Embeddings are vectorial representations of text that capture the semantic meaning of paragraphs through their position in a high dimensional vector space. Cohere Embeddings API offers cutting-edge, state-of-the-art embeddings for text, which can be used for many NLP tasks. == Available Models @@ -44,6 +46,8 @@ When choosing a model: * Use `embed-multilingual-v3.0` when working with multiple languages * Use the "light" variants when you need faster inference or have resource constraints +TIP: For multimodal embedding use cases (combining text and images), we recommend using the xref:api/embeddings/cohere-embeddings-multimodal.adoc[Cohere Multimodal Embedding model] instead. + == Prerequisites You will need to create an API key with Cohere to access Cohere embedding models. @@ -100,7 +104,7 @@ There has been a significant change in the Spring AI auto-configuration, starter Please refer to the https://docs.spring.io/spring-ai/reference/upgrade-notes.html[upgrade notes] for more information. ==== -Spring AI provides Spring Boot auto-configuration for the Cohere Embedding Model. +Spring AI provides Spring Boot auto-configuration for the Cohere Text Embedding Model. To enable it add the following dependency to your project's Maven `pom.xml` file: [source, xml] @@ -172,7 +176,6 @@ The prefix `spring.ai.cohere.embedding` is property prefix that configures the ` |==== | Property | Description | Default -| spring.ai.cohere.embedding.enabled (Removed and no longer valid) | Enable Cohere embedding model. | true | spring.ai.model.embedding | Enable Cohere embedding model. | cohere | spring.ai.cohere.embedding.base-url | Optional overrides the spring.ai.cohere.base-url to provide embedding specific url | - | spring.ai.cohere.embedding.api-key | Optional overrides the spring.ai.cohere.api-key to provide embedding specific api-key | - @@ -265,7 +268,7 @@ public class EmbeddingController { == Manual Configuration -If you are not using Spring Boot, you can manually configure the Cohere Embedding Model. +If you are not using Spring Boot, you can manually configure the Cohere Text Embedding Model. For this add the `spring-ai-cohere` dependency to your project's Maven `pom.xml` file: [source, xml] ---- From f608f8aca4c1968fc92bbb07ca139da90c279012 Mon Sep 17 00:00:00 2001 From: ricken07 Date: Fri, 28 Nov 2025 22:03:24 +0100 Subject: [PATCH 18/18] added cohere support :: fix chat multimodal Signed-off-by: ricken07 --- .../ai/cohere/api/CohereApi.java | 24 ++++- .../ai/cohere/chat/CohereChatModel.java | 7 +- .../ai/cohere/chat/CohereChatOptions.java | 28 ++++- .../ai/cohere/chat/CohereImageValidator.java | 101 ++++++++++++++++++ .../ai/cohere/chat/CohereChatModelIT.java | 31 ++++++ 5 files changed, 187 insertions(+), 4 deletions(-) create mode 100644 models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/chat/CohereImageValidator.java diff --git a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/api/CohereApi.java b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/api/CohereApi.java index c540b09a9c6..f79808832da 100644 --- a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/api/CohereApi.java +++ b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/api/CohereApi.java @@ -559,15 +559,37 @@ public MediaContent(ImageUrl imageUrl) { this("image_url", null, imageUrl); } + /** + * The level of detail for processing the image. + */ + public enum DetailLevel { + + @JsonProperty("low") + LOW, + + @JsonProperty("high") + HIGH, + + @JsonProperty("auto") + AUTO + + } + /** * Shortcut constructor for an image rawContent. * * @param url Either a URL of the image or the base64 encoded image data. The * base64 encoded image data must have a special prefix in the following * format: "data:{mimetype};base64,{base64-encoded-image-data}". + * @param detail The level of detail for processing the image. Can be "low", + * "high", or "auto". Defaults to "auto" if not specified. */ @JsonInclude(JsonInclude.Include.NON_NULL) - public record ImageUrl(@JsonProperty("url") String url) { + public record ImageUrl(@JsonProperty("url") String url, @JsonProperty("detail") DetailLevel detail) { + + public ImageUrl(String url) { + this(url, DetailLevel.AUTO); + } } } diff --git a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/chat/CohereChatModel.java b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/chat/CohereChatModel.java index ef89236eda3..0e8e8382333 100644 --- a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/chat/CohereChatModel.java +++ b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/chat/CohereChatModel.java @@ -510,6 +510,9 @@ private List convertUserMessage(org.springframework.ai.ch Object content = message.getText(); if (message instanceof UserMessage userMessage && !CollectionUtils.isEmpty(userMessage.getMedia())) { + // Validate images before processing + CohereImageValidator.validateImages(userMessage.getMedia()); + List contentList = new ArrayList<>( List.of(new ChatCompletionMessage.MediaContent(message.getText()))); @@ -572,8 +575,10 @@ private List convertToolMessage(Message message) { } private ChatCompletionMessage.MediaContent mapToMediaContent(Media media) { + CohereApi.ChatCompletionMessage.MediaContent.DetailLevel detail = this.defaultOptions != null + ? this.defaultOptions.getImageDetail() : null; return new ChatCompletionMessage.MediaContent(new ChatCompletionMessage.MediaContent.ImageUrl( - this.fromMediaData(media.getMimeType(), media.getData()))); + this.fromMediaData(media.getMimeType(), media.getData()), detail)); } private String fromMediaData(MimeType mimeType, Object mediaContentData) { diff --git a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/chat/CohereChatOptions.java b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/chat/CohereChatOptions.java index 5c5df1a5a37..6f9955180cf 100644 --- a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/chat/CohereChatOptions.java +++ b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/chat/CohereChatOptions.java @@ -30,6 +30,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import org.springframework.ai.cohere.api.CohereApi; +import org.springframework.ai.cohere.api.CohereApi.ChatCompletionMessage.MediaContent.DetailLevel; import org.springframework.ai.cohere.api.CohereApi.ChatCompletionRequest.ResponseFormat; import org.springframework.ai.cohere.api.CohereApi.ChatCompletionRequest.ToolChoice; import org.springframework.ai.cohere.api.CohereApi.FunctionTool; @@ -146,6 +147,14 @@ public class CohereChatOptions implements ToolCallingChatOptions { private @JsonProperty("strict_tools") Boolean strictTools; + /** + * The level of detail for processing images. Can be "low", "high", or "auto". + * Defaults to "auto" if not specified. This controls the resolution at which the + * model views image. + */ + @JsonIgnore + private DetailLevel imageDetail; + /** * Collection of {@link ToolCallback}s to be used for tool calling in the chat * completion requests. @@ -201,6 +210,14 @@ public void setStrictTools(Boolean strictTools) { this.strictTools = strictTools; } + public DetailLevel getImageDetail() { + return this.imageDetail; + } + + public void setImageDetail(DetailLevel imageDetail) { + this.imageDetail = imageDetail; + } + public Double getP() { return this.p; } @@ -393,6 +410,7 @@ public boolean equals(Object o) { && Objects.equals(this.stopSequences, that.stopSequences) && Objects.equals(this.seed, that.seed) && Objects.equals(this.logprobs, that.logprobs) && Objects.equals(this.toolChoice, that.toolChoice) && Objects.equals(this.strictTools, that.strictTools) + && Objects.equals(this.imageDetail, that.imageDetail) && Objects.equals(this.toolCallbacks, that.toolCallbacks) && Objects.equals(this.toolNames, that.toolNames) && Objects.equals(this.internalToolExecutionEnabled, that.internalToolExecutionEnabled) @@ -403,8 +421,8 @@ public boolean equals(Object o) { public int hashCode() { return Objects.hash(this.model, this.temperature, this.p, this.maxTokens, this.presencePenalty, this.frequencyPenalty, this.k, this.tools, this.responseFormat, this.safetyMode, this.stopSequences, - this.seed, this.logprobs, this.toolChoice, this.strictTools, this.toolCallbacks, this.toolNames, - this.internalToolExecutionEnabled, this.toolContext); + this.seed, this.logprobs, this.toolChoice, this.strictTools, this.imageDetail, this.toolCallbacks, + this.toolNames, this.internalToolExecutionEnabled, this.toolContext); } public static Builder builder() { @@ -425,6 +443,7 @@ public static CohereChatOptions fromOptions(CohereChatOptions fromOptions) { .logprobs(fromOptions.getLogprobs()) .toolChoice(fromOptions.getToolChoice()) .strictTools(fromOptions.getStrictTools()) + .imageDetail(fromOptions.getImageDetail()) .internalToolExecutionEnabled(fromOptions.getInternalToolExecutionEnabled()); // Create defensive copies of collections @@ -595,6 +614,11 @@ public Builder internalToolExecutionEnabled(@Nullable Boolean internalToolExecut return this; } + public Builder imageDetail(DetailLevel imageDetail) { + this.options.setImageDetail(imageDetail); + return this; + } + } } diff --git a/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/chat/CohereImageValidator.java b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/chat/CohereImageValidator.java new file mode 100644 index 00000000000..43ee99c300f --- /dev/null +++ b/models/spring-ai-cohere/src/main/java/org/springframework/ai/cohere/chat/CohereImageValidator.java @@ -0,0 +1,101 @@ +/* + * Copyright 2023-2025 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.cohere.chat; + +import java.util.List; +import java.util.Set; + +import org.springframework.ai.content.Media; + +/** + * Validator for Cohere API image constraints. + * + * @author Ricken Bazolo + */ +public final class CohereImageValidator { + + private static final int MAX_IMAGES_PER_REQUEST = 20; + + private static final long MAX_TOTAL_IMAGE_SIZE_BYTES = 20 * 1024 * 1024; + + private static final Set SUPPORTED_IMAGE_FORMATS = Set.of("image/jpeg", "image/png", "image/webp", + "image/gif"); + + private CohereImageValidator() { + } + + public static void validateImages(List mediaList) { + if (mediaList == null || mediaList.isEmpty()) { + return; + } + + validateImageCount(mediaList); + validateImageFormats(mediaList); + validateTotalImageSize(mediaList); + } + + private static void validateImageCount(List mediaList) { + if (mediaList.size() > MAX_IMAGES_PER_REQUEST) { + throw new IllegalArgumentException( + String.format("Cohere API supports maximum %d images per request, found: %d", + MAX_IMAGES_PER_REQUEST, mediaList.size())); + } + } + + private static void validateImageFormats(List mediaList) { + for (Media media : mediaList) { + var mimeType = media.getMimeType().toString(); + if (!SUPPORTED_IMAGE_FORMATS.contains(mimeType)) { + throw new IllegalArgumentException(String + .format("Unsupported image format: %s. Supported formats: JPEG, PNG, WebP, GIF", mimeType)); + } + } + } + + private static void validateTotalImageSize(List mediaList) { + long totalSize = 0; + + for (Media media : mediaList) { + long mediaSize = calculateMediaSize(media); + totalSize += mediaSize; + } + + if (totalSize > MAX_TOTAL_IMAGE_SIZE_BYTES) { + long totalSizeMB = totalSize / (1024 * 1024); + throw new IllegalArgumentException(String.format("Total image size exceeds 20MB limit: %dMB", totalSizeMB)); + } + } + + private static long calculateMediaSize(Media media) { + var data = media.getData(); + + if (data instanceof byte[] bytes) { + return bytes.length; + } + + if (data instanceof String text) { + if (text.startsWith("data:")) { + var base64Data = text.substring(text.indexOf(",") + 1); + return (long) (base64Data.length() * 0.75); + } + return 0; + } + + return 0; + } + +} diff --git a/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/chat/CohereChatModelIT.java b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/chat/CohereChatModelIT.java index 18bc37c1231..81798284e5b 100644 --- a/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/chat/CohereChatModelIT.java +++ b/models/spring-ai-cohere/src/test/java/org/springframework/ai/cohere/chat/CohereChatModelIT.java @@ -16,6 +16,8 @@ package org.springframework.ai.cohere.chat; +import java.io.IOException; +import java.net.URI; import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -43,8 +45,10 @@ import org.springframework.ai.chat.prompt.SystemPromptTemplate; import org.springframework.ai.cohere.CohereTestConfiguration; import org.springframework.ai.cohere.api.CohereApi; +import org.springframework.ai.cohere.api.CohereApi.ChatModel; import org.springframework.ai.cohere.api.tool.MockWeatherService; import org.springframework.ai.cohere.testutils.AbstractIT; +import org.springframework.ai.content.Media; import org.springframework.ai.converter.BeanOutputConverter; import org.springframework.ai.converter.ListOutputConverter; import org.springframework.ai.converter.MapOutputConverter; @@ -57,6 +61,7 @@ import org.springframework.ai.tool.function.FunctionToolCallback; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.core.convert.support.DefaultConversionService; +import org.springframework.util.MimeTypeUtils; import static org.assertj.core.api.Assertions.assertThat; @@ -321,6 +326,32 @@ void chatMemoryWithTools() { assertThat(newResponse.getResult().getOutput().getText()).contains("6").contains("8"); } + @Test + void streamingMultiModalityImageUrl() throws IOException { + + var userMessage = UserMessage.builder() + .text("Explain what do you see on this picture?") + .media(List.of(Media.builder() + .mimeType(MimeTypeUtils.IMAGE_PNG) + .data(URI.create("https://docs.spring.io/spring-ai/reference/_images/multimodal.test.png")) + .build())) + .build(); + + Flux response = this.streamingChatModel.stream(new Prompt(List.of(userMessage), + CohereChatOptions.builder().model(ChatModel.COMMAND_A_VISION.getValue()).build())); + + String content = response.collectList() + .block() + .stream() + .map(ChatResponse::getResults) + .flatMap(List::stream) + .map(Generation::getOutput) + .map(AssistantMessage::getText) + .collect(Collectors.joining()); + logger.info("Response: {}", content); + assertThat(content).containsAnyOf("bananas", "apple", "bowl", "basket", "fruit stand"); + } + static class MathTools { @Tool(description = "Multiply the two numbers")